appback-remoteagent 0.13.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.
Files changed (46) hide show
  1. package/.env.example +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +371 -0
  4. package/bin/remoteagent.js +2 -0
  5. package/dist/adapters/claude-adapter.js +78 -0
  6. package/dist/adapters/codex-adapter.js +241 -0
  7. package/dist/adapters/provider-adapter.js +1 -0
  8. package/dist/adapters/shell-adapter.js +44 -0
  9. package/dist/adapters/windows-shell.js +111 -0
  10. package/dist/bot.js +2135 -0
  11. package/dist/config.js +170 -0
  12. package/dist/index.js +534 -0
  13. package/dist/secret-helper.js +24 -0
  14. package/dist/services/agent-memory-service.js +737 -0
  15. package/dist/services/bot-management-service.js +626 -0
  16. package/dist/services/bridge-service.js +807 -0
  17. package/dist/services/local-ui-service.js +533 -0
  18. package/dist/services/provider-setup-service.js +284 -0
  19. package/dist/services/remote-shell-service.js +97 -0
  20. package/dist/store/file-store.js +690 -0
  21. package/dist/telegram-fetch.js +85 -0
  22. package/dist/types.js +1 -0
  23. package/docs/ARCHITECTURE.md +170 -0
  24. package/docs/COKACDIR_NOTES.md +79 -0
  25. package/docs/ERROR_NORMALIZATION.md +46 -0
  26. package/docs/MINI_APP.md +112 -0
  27. package/docs/MVP.md +108 -0
  28. package/docs/OPERATIONS.md +181 -0
  29. package/docs/RELEASING.md +87 -0
  30. package/docs/SESSION_DIRECTORY_PLAN.md +506 -0
  31. package/package.json +47 -0
  32. package/scripts/bump-version.sh +23 -0
  33. package/scripts/finish-claude-login.sh +48 -0
  34. package/scripts/install-claude.sh +6 -0
  35. package/scripts/install-codex.sh +8 -0
  36. package/scripts/install.ps1 +51 -0
  37. package/scripts/install.sh +101 -0
  38. package/scripts/mock-adapter.sh +7 -0
  39. package/scripts/restart-after-bot-op.sh +118 -0
  40. package/scripts/selftest-telegram-update.mjs +359 -0
  41. package/scripts/start-claude-login.sh +4 -0
  42. package/scripts/start.ps1 +39 -0
  43. package/scripts/start.sh +54 -0
  44. package/scripts/stop.ps1 +40 -0
  45. package/scripts/stop.sh +39 -0
  46. package/tsconfig.json +20 -0
@@ -0,0 +1,690 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ const EMPTY_STATE = { chats: {}, sessions: {}, telegramContacts: {}, settings: {} };
5
+ export class FileStore {
6
+ dataDir;
7
+ defaultMode;
8
+ stateFile;
9
+ legacyLogsDir;
10
+ sessionsDir;
11
+ telegramChannelsDir;
12
+ constructor(dataDir, defaultMode) {
13
+ this.dataDir = dataDir;
14
+ this.defaultMode = defaultMode;
15
+ this.stateFile = path.join(dataDir, "state.json");
16
+ this.legacyLogsDir = path.join(dataDir, "logs");
17
+ this.sessionsDir = path.join(dataDir, "sessions");
18
+ this.telegramChannelsDir = path.join(dataDir, "channels", "telegram");
19
+ }
20
+ async init() {
21
+ await fs.mkdir(this.legacyLogsDir, { recursive: true });
22
+ await fs.mkdir(this.sessionsDir, { recursive: true });
23
+ await fs.mkdir(this.telegramChannelsDir, { recursive: true });
24
+ let state = await this.readState();
25
+ if (Object.keys(state.chats).length === 0) {
26
+ const recovered = await this.recoverBindingsFromLogs(state);
27
+ if (Object.keys(recovered.chats).length > 0) {
28
+ state = recovered;
29
+ }
30
+ }
31
+ await this.writeState(state);
32
+ }
33
+ async getChatSession(botId, chatId) {
34
+ const state = await this.readState();
35
+ const migrated = this.materializeBindingForBot(state, botId, chatId);
36
+ if (migrated) {
37
+ await this.writeState(state);
38
+ }
39
+ return this.resolveChatSession(state, botId, chatId);
40
+ }
41
+ async listSessions(botId) {
42
+ const state = await this.readState();
43
+ const sessions = Object.values(state.sessions);
44
+ if (!botId) {
45
+ return sessions.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
46
+ }
47
+ const sessionIds = new Set(Object.values(state.chats)
48
+ .filter((binding) => binding.botId === botId)
49
+ .map((binding) => binding.sessionId));
50
+ return sessions
51
+ .filter((session) => sessionIds.has(session.sessionId))
52
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
53
+ }
54
+ async listActiveSessionIds(botId) {
55
+ const state = await this.readState();
56
+ const sessionIds = Object.values(state.chats)
57
+ .filter((binding) => !botId || binding.botId === botId)
58
+ .map((binding) => binding.sessionId);
59
+ return new Set(sessionIds);
60
+ }
61
+ async getSession(sessionId) {
62
+ const state = await this.readState();
63
+ return state.sessions[sessionId];
64
+ }
65
+ async getDefaultStartMode() {
66
+ const state = await this.readState();
67
+ return state.settings.defaultStartMode;
68
+ }
69
+ async ensureDefaultStartMode(provider) {
70
+ const state = await this.readState();
71
+ if (state.settings.defaultStartMode) {
72
+ return;
73
+ }
74
+ state.settings.defaultStartMode = provider;
75
+ await this.writeState(state);
76
+ }
77
+ async createSessionForChat(botId, chatId, workspace, mode, workspaceUid) {
78
+ const state = await this.readState();
79
+ const now = new Date().toISOString();
80
+ const sessionId = randomUUID();
81
+ state.sessions[sessionId] = {
82
+ sessionId,
83
+ publicId: this.nextPublicSessionId(state),
84
+ mode: mode ?? this.defaultMode,
85
+ workspace,
86
+ workspaceUid,
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ };
90
+ state.chats[this.chatKey(botId, chatId)] = {
91
+ botId,
92
+ chatId,
93
+ sessionId,
94
+ boundAt: now,
95
+ updatedAt: now,
96
+ };
97
+ delete state.chats[chatId];
98
+ await this.writeState(state);
99
+ return this.mustResolveChatSession(state, botId, chatId);
100
+ }
101
+ async bindChatToSession(botId, chatId, sessionId) {
102
+ const state = await this.readState();
103
+ const record = state.sessions[sessionId];
104
+ if (!record) {
105
+ throw new Error(`Session was not found: ${sessionId}`);
106
+ }
107
+ const now = new Date().toISOString();
108
+ const existing = this.resolveBinding(state, botId, chatId);
109
+ state.chats[this.chatKey(botId, chatId)] = {
110
+ botId,
111
+ chatId,
112
+ sessionId,
113
+ boundAt: existing?.boundAt ?? now,
114
+ updatedAt: now,
115
+ };
116
+ delete state.chats[chatId];
117
+ await this.writeState(state);
118
+ return this.mustResolveChatSession(state, botId, chatId);
119
+ }
120
+ async upsertProviderForChat(botId, chatId, provider, session, workspace) {
121
+ const state = await this.readState();
122
+ const now = new Date().toISOString();
123
+ const { binding, record } = this.ensureBoundSession(state, botId, chatId, workspace, now);
124
+ record.workspace = workspace;
125
+ record[provider] = {
126
+ provider,
127
+ ...session,
128
+ };
129
+ record.updatedAt = now;
130
+ binding.updatedAt = now;
131
+ await this.writeState(state);
132
+ return this.mustResolveChatSession(state, botId, chatId);
133
+ }
134
+ async upsertProviderForSession(sessionId, provider, session, workspace) {
135
+ const state = await this.readState();
136
+ const record = state.sessions[sessionId];
137
+ if (!record) {
138
+ throw new Error(`Session was not found: ${sessionId}`);
139
+ }
140
+ record.workspace = workspace;
141
+ record[provider] = {
142
+ provider,
143
+ ...session,
144
+ };
145
+ record.updatedAt = new Date().toISOString();
146
+ await this.writeState(state);
147
+ return record;
148
+ }
149
+ async setModeForChat(botId, chatId, mode) {
150
+ const state = await this.readState();
151
+ const now = new Date().toISOString();
152
+ const { binding, record } = this.ensureBoundSession(state, botId, chatId, this.dataDir, now);
153
+ record.mode = mode;
154
+ record.updatedAt = now;
155
+ binding.updatedAt = now;
156
+ await this.writeState(state);
157
+ return this.mustResolveChatSession(state, botId, chatId);
158
+ }
159
+ async resetChat(botId, chatId) {
160
+ const state = await this.readState();
161
+ delete state.chats[this.chatKey(botId, chatId)];
162
+ delete state.chats[chatId];
163
+ await this.writeState(state);
164
+ await fs.rm(this.channelFilePath(botId, chatId), { force: true }).catch(() => undefined);
165
+ }
166
+ async rememberTelegramContact(contact) {
167
+ const state = await this.readState();
168
+ const key = this.chatKey(contact.botId, contact.chatId);
169
+ const existing = state.telegramContacts[key];
170
+ state.telegramContacts[key] = {
171
+ ...existing,
172
+ ...contact,
173
+ transport: "telegram",
174
+ lastSeenAt: contact.lastSeenAt,
175
+ };
176
+ await this.writeState(state);
177
+ }
178
+ async listTelegramContacts() {
179
+ const state = await this.readState();
180
+ return Object.values(state.telegramContacts).sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt));
181
+ }
182
+ async appendLog(remoteSessionId, line) {
183
+ const eventFile = this.sessionEventsPath(remoteSessionId);
184
+ await fs.mkdir(path.dirname(eventFile), { recursive: true });
185
+ await fs.appendFile(eventFile, `${line}\n`, "utf8");
186
+ const legacyLogFile = path.join(this.legacyLogsDir, `${remoteSessionId}.jsonl`);
187
+ await fs.appendFile(legacyLogFile, `${line}\n`, "utf8");
188
+ }
189
+ async readLogs(remoteSessionId, limit = 200) {
190
+ const eventFile = this.sessionEventsPath(remoteSessionId);
191
+ const primary = await this.readLogFile(eventFile);
192
+ if (primary.length > 0) {
193
+ return primary.slice(Math.max(0, primary.length - limit));
194
+ }
195
+ const legacyLogFile = path.join(this.legacyLogsDir, `${remoteSessionId}.jsonl`);
196
+ const legacy = await this.readLogFile(legacyLogFile);
197
+ return legacy.slice(Math.max(0, legacy.length - limit));
198
+ }
199
+ async readState() {
200
+ const directoryState = await this.readStateFromDirectories();
201
+ const legacyState = await this.readLegacyState();
202
+ const state = this.mergeStates(directoryState, legacyState);
203
+ this.ensurePublicSessionIds(state);
204
+ return state;
205
+ }
206
+ async writeState(state) {
207
+ await fs.mkdir(this.sessionsDir, { recursive: true });
208
+ await fs.mkdir(this.telegramChannelsDir, { recursive: true });
209
+ await this.writeJsonFileAtomic(this.stateFile, state);
210
+ await this.writeSessions(state.sessions);
211
+ await this.writeChannelBindings(state.chats);
212
+ }
213
+ async readStateFromDirectories() {
214
+ const state = { chats: {}, sessions: {}, telegramContacts: {}, settings: {} };
215
+ const sessionDirs = await fs.readdir(this.sessionsDir, { withFileTypes: true }).catch(() => []);
216
+ for (const entry of sessionDirs) {
217
+ if (!entry.isDirectory()) {
218
+ continue;
219
+ }
220
+ const sessionFile = path.join(this.sessionsDir, entry.name, "session.json");
221
+ const session = await this.readJsonFile(sessionFile);
222
+ if (!session) {
223
+ continue;
224
+ }
225
+ state.sessions[session.sessionId] = this.normalizeSession(session);
226
+ }
227
+ const botDirs = await fs.readdir(this.telegramChannelsDir, { withFileTypes: true }).catch(() => []);
228
+ for (const botEntry of botDirs) {
229
+ if (!botEntry.isDirectory()) {
230
+ continue;
231
+ }
232
+ const botId = decodeURIComponent(botEntry.name);
233
+ const botDir = path.join(this.telegramChannelsDir, botEntry.name);
234
+ const bindingFiles = await fs.readdir(botDir, { withFileTypes: true }).catch(() => []);
235
+ for (const fileEntry of bindingFiles) {
236
+ if (!fileEntry.isFile() || !fileEntry.name.endsWith(".json")) {
237
+ continue;
238
+ }
239
+ const binding = await this.readJsonFile(path.join(botDir, fileEntry.name));
240
+ if (!binding?.chatId || !binding.sessionId) {
241
+ continue;
242
+ }
243
+ binding.botId = binding.botId || botId;
244
+ state.chats[this.chatKey(binding.botId, binding.chatId)] = binding;
245
+ }
246
+ }
247
+ return state;
248
+ }
249
+ async readLegacyState() {
250
+ try {
251
+ const raw = await fs.readFile(this.stateFile, "utf8");
252
+ const { state } = this.normalizeState(this.parseJsonObject(raw, this.stateFile));
253
+ return state;
254
+ }
255
+ catch (error) {
256
+ if (error.code === "ENOENT") {
257
+ return EMPTY_STATE;
258
+ }
259
+ throw error;
260
+ }
261
+ }
262
+ async writeSessions(sessions) {
263
+ for (const session of Object.values(sessions)) {
264
+ const normalized = this.normalizeSession(session);
265
+ const sessionDir = this.sessionDirPath(session.sessionId);
266
+ await fs.mkdir(sessionDir, { recursive: true });
267
+ await this.writeJsonFileAtomic(path.join(sessionDir, "session.json"), normalized);
268
+ }
269
+ }
270
+ async writeChannelBindings(chats) {
271
+ const desiredFiles = new Set();
272
+ for (const binding of Object.values(chats)) {
273
+ if (!binding.botId) {
274
+ continue;
275
+ }
276
+ const filePath = this.channelFilePath(binding.botId, binding.chatId);
277
+ desiredFiles.add(filePath);
278
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
279
+ await this.writeJsonFileAtomic(filePath, binding);
280
+ }
281
+ const existingFiles = await this.listChannelFiles();
282
+ for (const filePath of existingFiles) {
283
+ if (!desiredFiles.has(filePath)) {
284
+ await fs.rm(filePath, { force: true }).catch(() => undefined);
285
+ }
286
+ }
287
+ }
288
+ async listChannelFiles() {
289
+ const files = [];
290
+ const botDirs = await fs.readdir(this.telegramChannelsDir, { withFileTypes: true }).catch(() => []);
291
+ for (const botEntry of botDirs) {
292
+ if (!botEntry.isDirectory()) {
293
+ continue;
294
+ }
295
+ const botDir = path.join(this.telegramChannelsDir, botEntry.name);
296
+ const entries = await fs.readdir(botDir, { withFileTypes: true }).catch(() => []);
297
+ for (const entry of entries) {
298
+ if (entry.isFile() && entry.name.endsWith(".json")) {
299
+ files.push(path.join(botDir, entry.name));
300
+ }
301
+ }
302
+ }
303
+ return files;
304
+ }
305
+ async readLogFile(filePath) {
306
+ const raw = await fs.readFile(filePath, "utf8").catch((error) => {
307
+ if (error.code === "ENOENT") {
308
+ return "";
309
+ }
310
+ throw error;
311
+ });
312
+ const entries = [];
313
+ for (const line of raw.split(/\r?\n/)) {
314
+ if (!line.trim()) {
315
+ continue;
316
+ }
317
+ try {
318
+ entries.push(JSON.parse(line));
319
+ }
320
+ catch {
321
+ continue;
322
+ }
323
+ }
324
+ return entries;
325
+ }
326
+ async readJsonFile(filePath) {
327
+ const raw = await fs.readFile(filePath, "utf8").catch((error) => {
328
+ if (error.code === "ENOENT") {
329
+ return undefined;
330
+ }
331
+ throw error;
332
+ });
333
+ if (!raw) {
334
+ return undefined;
335
+ }
336
+ try {
337
+ return this.parseJsonObject(raw, filePath);
338
+ }
339
+ catch {
340
+ return undefined;
341
+ }
342
+ }
343
+ parseJsonObject(raw, source) {
344
+ try {
345
+ return JSON.parse(raw);
346
+ }
347
+ catch (error) {
348
+ const end = this.findJsonObjectEnd(raw);
349
+ if (!end) {
350
+ throw error;
351
+ }
352
+ const trailing = raw.slice(end).trim();
353
+ if (!trailing) {
354
+ throw error;
355
+ }
356
+ console.warn(`[store] ${source} has ${trailing.length} trailing character(s) after the first JSON object; ignoring trailing data.`);
357
+ return JSON.parse(raw.slice(0, end));
358
+ }
359
+ }
360
+ findJsonObjectEnd(raw) {
361
+ let depth = 0;
362
+ let inString = false;
363
+ let escaped = false;
364
+ let started = false;
365
+ for (let index = 0; index < raw.length; index += 1) {
366
+ const char = raw[index];
367
+ if (!started) {
368
+ if (/\s/.test(char)) {
369
+ continue;
370
+ }
371
+ if (char !== "{") {
372
+ return undefined;
373
+ }
374
+ started = true;
375
+ depth = 1;
376
+ continue;
377
+ }
378
+ if (inString) {
379
+ if (escaped) {
380
+ escaped = false;
381
+ }
382
+ else if (char === "\\") {
383
+ escaped = true;
384
+ }
385
+ else if (char === "\"") {
386
+ inString = false;
387
+ }
388
+ continue;
389
+ }
390
+ if (char === "\"") {
391
+ inString = true;
392
+ }
393
+ else if (char === "{") {
394
+ depth += 1;
395
+ }
396
+ else if (char === "}") {
397
+ depth -= 1;
398
+ if (depth === 0) {
399
+ return index + 1;
400
+ }
401
+ }
402
+ }
403
+ return undefined;
404
+ }
405
+ async writeJsonFileAtomic(filePath, value) {
406
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
407
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
408
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
409
+ await fs.rename(temporaryPath, filePath);
410
+ }
411
+ normalizeState(rawState) {
412
+ if ("sessions" in rawState) {
413
+ const state = rawState;
414
+ state.telegramContacts = state.telegramContacts ?? {};
415
+ state.settings = this.normalizeSettings(state.settings);
416
+ for (const session of Object.values(state.sessions)) {
417
+ this.normalizeSession(session);
418
+ }
419
+ for (const [key, contact] of Object.entries(state.telegramContacts)) {
420
+ const normalized = this.normalizeTelegramContact(contact);
421
+ if (normalized) {
422
+ state.telegramContacts[key] = normalized;
423
+ }
424
+ else {
425
+ delete state.telegramContacts[key];
426
+ }
427
+ }
428
+ return { state, migrated: false };
429
+ }
430
+ const legacy = rawState;
431
+ const migrated = { chats: {}, sessions: {}, telegramContacts: {}, settings: {} };
432
+ for (const [chatId, mapping] of Object.entries(legacy.chats || {})) {
433
+ const sessionId = randomUUID();
434
+ const now = mapping.updatedAt || new Date().toISOString();
435
+ const workspace = mapping.codex?.cwd || mapping.claude?.cwd || this.dataDir;
436
+ migrated.sessions[sessionId] = {
437
+ sessionId,
438
+ publicId: this.nextPublicSessionId(migrated),
439
+ mode: mapping.mode || this.defaultMode,
440
+ workspace,
441
+ codex: mapping.codex ? { ...mapping.codex, provider: "codex", cwd: mapping.codex.cwd || workspace } : undefined,
442
+ claude: mapping.claude ? { ...mapping.claude, provider: "claude", cwd: mapping.claude.cwd || workspace } : undefined,
443
+ createdAt: now,
444
+ updatedAt: now,
445
+ };
446
+ migrated.chats[this.chatKey(mapping.botId, chatId)] = {
447
+ botId: mapping.botId,
448
+ chatId,
449
+ sessionId,
450
+ boundAt: now,
451
+ updatedAt: now,
452
+ };
453
+ }
454
+ return { state: migrated, migrated: true };
455
+ }
456
+ mergeStates(primary, fallback) {
457
+ const state = {
458
+ chats: { ...fallback.chats, ...primary.chats },
459
+ sessions: { ...fallback.sessions, ...primary.sessions },
460
+ telegramContacts: { ...fallback.telegramContacts, ...primary.telegramContacts },
461
+ settings: this.normalizeSettings({ ...fallback.settings, ...primary.settings }),
462
+ };
463
+ for (const session of Object.values(state.sessions)) {
464
+ this.normalizeSession(session);
465
+ }
466
+ for (const [key, contact] of Object.entries(state.telegramContacts)) {
467
+ const normalized = this.normalizeTelegramContact(contact);
468
+ if (normalized) {
469
+ state.telegramContacts[key] = normalized;
470
+ }
471
+ else {
472
+ delete state.telegramContacts[key];
473
+ }
474
+ }
475
+ return state;
476
+ }
477
+ async recoverBindingsFromLogs(state) {
478
+ const latestByChat = new Map();
479
+ for (const session of Object.values(state.sessions)) {
480
+ const eventFile = this.sessionEventsPath(session.sessionId);
481
+ const primaryEntries = await this.readLogFile(eventFile);
482
+ const entries = primaryEntries.length > 0
483
+ ? primaryEntries
484
+ : await this.readLogFile(path.join(this.legacyLogsDir, `${session.sessionId}.jsonl`));
485
+ for (const entry of entries) {
486
+ if (!entry.chatId) {
487
+ continue;
488
+ }
489
+ const current = latestByChat.get(entry.chatId);
490
+ if (!current || current.timestamp.localeCompare(entry.timestamp) < 0) {
491
+ latestByChat.set(entry.chatId, {
492
+ sessionId: session.sessionId,
493
+ timestamp: entry.timestamp,
494
+ });
495
+ }
496
+ }
497
+ }
498
+ for (const [chatId, recovered] of latestByChat.entries()) {
499
+ if (state.chats[chatId]) {
500
+ continue;
501
+ }
502
+ state.chats[chatId] = {
503
+ chatId,
504
+ sessionId: recovered.sessionId,
505
+ boundAt: recovered.timestamp,
506
+ updatedAt: recovered.timestamp,
507
+ };
508
+ }
509
+ return state;
510
+ }
511
+ normalizeSettings(settings) {
512
+ const normalized = settings ? { ...settings } : {};
513
+ if (normalized.defaultStartMode !== "codex" && normalized.defaultStartMode !== "claude") {
514
+ delete normalized.defaultStartMode;
515
+ }
516
+ return normalized;
517
+ }
518
+ normalizeSession(session) {
519
+ session.publicId = session.publicId || "";
520
+ session.workspace = session.workspace || session.codex?.cwd || session.claude?.cwd || this.dataDir;
521
+ session.workspaceUid = typeof session.workspaceUid === "string" && session.workspaceUid.trim() ? session.workspaceUid.trim() : undefined;
522
+ session.createdAt = session.createdAt || session.updatedAt || new Date().toISOString();
523
+ session.updatedAt = session.updatedAt || session.createdAt;
524
+ if (session.mode !== "codex" && session.mode !== "claude") {
525
+ session.mode = session.codex ? "codex" : session.claude ? "claude" : this.defaultMode;
526
+ }
527
+ for (const provider of ["codex", "claude"]) {
528
+ const binding = session[provider];
529
+ if (binding && !binding.cwd) {
530
+ binding.cwd = session.workspace;
531
+ }
532
+ }
533
+ return session;
534
+ }
535
+ normalizeTelegramContact(contact) {
536
+ if (contact.transport !== "telegram" || !contact.botId || !contact.chatId) {
537
+ return undefined;
538
+ }
539
+ return {
540
+ ...contact,
541
+ transport: "telegram",
542
+ chatType: contact.chatType || "private",
543
+ lastSeenAt: contact.lastSeenAt || new Date().toISOString(),
544
+ };
545
+ }
546
+ resolveChatSession(state, botId, chatId) {
547
+ const binding = this.resolveBinding(state, botId, chatId);
548
+ if (!binding) {
549
+ return undefined;
550
+ }
551
+ const session = state.sessions[binding.sessionId];
552
+ if (!session) {
553
+ return undefined;
554
+ }
555
+ return {
556
+ botId: binding.botId ?? botId,
557
+ chatId,
558
+ binding,
559
+ session,
560
+ };
561
+ }
562
+ mustResolveChatSession(state, botId, chatId) {
563
+ const result = this.resolveChatSession(state, botId, chatId);
564
+ if (!result) {
565
+ throw new Error(`Chat binding was not found for bot ${botId} chat ${chatId}.`);
566
+ }
567
+ return result;
568
+ }
569
+ ensureBoundSession(state, botId, chatId, workspace, now) {
570
+ const exactKey = this.chatKey(botId, chatId);
571
+ const migrated = this.materializeBindingForBot(state, botId, chatId);
572
+ let binding = state.chats[exactKey] ?? (migrated ? state.chats[exactKey] : undefined);
573
+ let record = binding ? state.sessions[binding.sessionId] : undefined;
574
+ if (!binding || !record) {
575
+ const sessionId = randomUUID();
576
+ record = {
577
+ sessionId,
578
+ publicId: this.nextPublicSessionId(state),
579
+ mode: this.defaultMode,
580
+ workspace,
581
+ createdAt: now,
582
+ updatedAt: now,
583
+ };
584
+ binding = {
585
+ botId,
586
+ chatId,
587
+ sessionId,
588
+ boundAt: now,
589
+ updatedAt: now,
590
+ };
591
+ state.sessions[sessionId] = record;
592
+ state.chats[exactKey] = binding;
593
+ return { binding, record };
594
+ }
595
+ if (!state.chats[exactKey]) {
596
+ binding = {
597
+ ...binding,
598
+ botId,
599
+ chatId,
600
+ };
601
+ state.chats[exactKey] = binding;
602
+ }
603
+ else if (!binding.botId) {
604
+ binding.botId = botId;
605
+ }
606
+ return { binding, record };
607
+ }
608
+ resolveBinding(state, botId, chatId) {
609
+ return state.chats[this.chatKey(botId, chatId)];
610
+ }
611
+ materializeBindingForBot(state, botId, chatId) {
612
+ const exactKey = this.chatKey(botId, chatId);
613
+ if (state.chats[exactKey]) {
614
+ return false;
615
+ }
616
+ const legacyBinding = state.chats[chatId];
617
+ if (!legacyBinding) {
618
+ return false;
619
+ }
620
+ state.chats[exactKey] = {
621
+ ...legacyBinding,
622
+ botId,
623
+ chatId,
624
+ };
625
+ delete state.chats[chatId];
626
+ return true;
627
+ }
628
+ sessionDirPath(sessionId) {
629
+ return path.join(this.sessionsDir, encodeURIComponent(sessionId));
630
+ }
631
+ sessionEventsPath(sessionId) {
632
+ return path.join(this.sessionDirPath(sessionId), "events.jsonl");
633
+ }
634
+ channelFilePath(botId, chatId) {
635
+ return path.join(this.telegramChannelsDir, encodeURIComponent(botId), `${encodeURIComponent(chatId)}.json`);
636
+ }
637
+ chatKey(botId, chatId) {
638
+ return botId ? `${botId}:${chatId}` : chatId;
639
+ }
640
+ ensurePublicSessionIds(state) {
641
+ const used = new Set();
642
+ for (const session of Object.values(state.sessions)) {
643
+ const parsed = this.parsePublicSessionNumber(session.publicId);
644
+ if (parsed !== undefined) {
645
+ used.add(parsed);
646
+ }
647
+ }
648
+ const missing = Object.values(state.sessions)
649
+ .filter((session) => this.parsePublicSessionNumber(session.publicId) === undefined)
650
+ .sort((left, right) => {
651
+ const timeCompare = left.createdAt.localeCompare(right.createdAt);
652
+ if (timeCompare !== 0) {
653
+ return timeCompare;
654
+ }
655
+ return left.sessionId.localeCompare(right.sessionId);
656
+ });
657
+ for (const session of missing) {
658
+ let next = 1;
659
+ while (used.has(next)) {
660
+ next += 1;
661
+ }
662
+ session.publicId = this.formatPublicSessionId(next);
663
+ used.add(next);
664
+ }
665
+ }
666
+ nextPublicSessionId(state) {
667
+ let max = 0;
668
+ for (const session of Object.values(state.sessions)) {
669
+ const parsed = this.parsePublicSessionNumber(session.publicId);
670
+ if (parsed !== undefined && parsed > max) {
671
+ max = parsed;
672
+ }
673
+ }
674
+ return this.formatPublicSessionId(max + 1);
675
+ }
676
+ parsePublicSessionNumber(publicId) {
677
+ if (!publicId) {
678
+ return undefined;
679
+ }
680
+ const match = publicId.trim().match(/^S(\d+)$/i);
681
+ if (!match) {
682
+ return undefined;
683
+ }
684
+ const parsed = Number.parseInt(match[1], 10);
685
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
686
+ }
687
+ formatPublicSessionId(value) {
688
+ return `S${String(value).padStart(3, "0")}`;
689
+ }
690
+ }