@inetafrica/open-claudia 2.14.8 → 3.0.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 (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +105 -7
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +127 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
package/core/state.js CHANGED
@@ -4,7 +4,11 @@
4
4
 
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
- const { STATE_FILE, SESSIONS_FILE, IDENTITIES_FILE, CONFIG_DIR, CHAT_ID, UNIFIED_IDENTITY } = require("./config");
7
+ const crypto = require("crypto");
8
+ const {
9
+ STATE_FILE, SESSIONS_FILE, IDENTITIES_FILE, CONFIG_DIR, CHAT_ID, UNIFIED_IDENTITY,
10
+ JOBS_FILE, CRONS_FILE,
11
+ } = require("./config");
8
12
  const {
9
13
  normalizeCanonicalUserId,
10
14
  canonicalForTelegram,
@@ -17,50 +21,493 @@ const {
17
21
  identities,
18
22
  } = require("./identity");
19
23
  const { currentChannelId, currentCanonicalUserId } = require("./context");
24
+ const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
25
+ const {
26
+ defaultMigrationSources,
27
+ markMigrationComponentActivated,
28
+ markMigrationComponentPrepared,
29
+ requireMigrationSnapshotForComponent,
30
+ } = require("./migration-backup");
20
31
 
21
- function loadStateFile() {
22
- try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")); } catch (e) { return {}; }
32
+ const STATE_SCHEMA_VERSION = 3;
33
+ const SESSIONS_SCHEMA_VERSION = 3;
34
+ const MAX_USAGE_ENTRIES = 50;
35
+ const MAX_SESSIONS_PER_PROJECT = 20;
36
+ const MAX_SESSION_CALLBACKS = 200;
37
+ const SESSION_CALLBACK_TTL_MS = 10 * 60 * 1000;
38
+ const PROVIDER_SETTING_KEYS = Object.freeze(["model", "effort", "budget", "permissionMode", "worktree"]);
39
+ const LEGACY_POINTERS = Object.freeze({
40
+ lastSessionId: "claude",
41
+ codexSessionId: "codex",
42
+ });
43
+ const REMOVED_PROVIDER_POINTERS = Object.freeze({ cursorSessionId: "cursor" });
44
+ const ACTIVE_PROVIDER_IDS = new Set(["claude", "codex"]);
45
+ const SELECTABLE_SESSION_PROVIDERS = new Set(ACTIVE_PROVIDER_IDS);
46
+ const sessionCallbackTokens = new Map();
47
+
48
+ function isRecord(value) {
49
+ return !!value && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ function cloneRecord(value) {
53
+ return isRecord(value) ? { ...value } : {};
54
+ }
55
+
56
+ function providerDefaults() {
57
+ return { model: null, effort: null, budget: null, permissionMode: null, worktree: false };
58
+ }
59
+
60
+ function normalizeProviderId(providerId, fallback = null) {
61
+ const value = String(providerId || "").trim().toLowerCase();
62
+ return ACTIVE_PROVIDER_IDS.has(value) ? value : fallback;
63
+ }
64
+
65
+ function normalizeProviderSettings(value) {
66
+ const source = cloneRecord(value);
67
+ const result = {};
68
+ for (const providerId of ["claude", "codex"]) {
69
+ const provider = normalizeProviderId(providerId, null);
70
+ if (!provider) continue;
71
+ const settings = cloneRecord(source[providerId]);
72
+ result[provider] = { ...providerDefaults() };
73
+ for (const key of PROVIDER_SETTING_KEYS) {
74
+ if (Object.prototype.hasOwnProperty.call(settings, key)) result[provider][key] = settings[key];
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ function normalizeActiveSessions(value) {
81
+ const result = {};
82
+ for (const [projectName, providers] of Object.entries(cloneRecord(value))) {
83
+ const project = String(projectName || "").trim();
84
+ if (!project || !isRecord(providers)) continue;
85
+ const bucket = {};
86
+ for (const [providerId, sessionId] of Object.entries(providers)) {
87
+ const provider = normalizeProviderId(providerId, null);
88
+ if (!provider || !ACTIVE_PROVIDER_IDS.has(provider) || typeof sessionId !== "string" || !sessionId) continue;
89
+ bucket[provider] = sessionId;
90
+ }
91
+ if (Object.keys(bucket).length) result[project] = bucket;
92
+ }
93
+ return result;
94
+ }
95
+
96
+ function boundUsageBySession(value, limit = MAX_USAGE_ENTRIES) {
97
+ const entries = Object.entries(cloneRecord(value))
98
+ .filter(([key, usage]) => typeof key === "string" && key && isRecord(usage));
99
+ return Object.fromEntries(entries.slice(-Math.max(1, limit)));
100
+ }
101
+
102
+ function freshUsage() {
103
+ return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0, liveContextTokens: 0, lastUsageScope: "turn_delta" };
104
+ }
105
+
106
+ function usageSessionKey(projectName, providerId, sessionId) {
107
+ const project = String(projectName || "").trim();
108
+ const provider = normalizeProviderId(providerId, null);
109
+ if (!project || !provider) return null;
110
+ return `${project}\u0000${provider}\u0000${sessionId || "new"}`;
111
+ }
112
+
113
+ function projectNameFrom(value) {
114
+ if (typeof value === "string") return value.trim() || null;
115
+ if (isRecord(value)) return String(value.name || "").trim() || null;
116
+ return null;
117
+ }
118
+
119
+ function commonSettingsFrom(value) {
120
+ const settings = cloneRecord(value);
121
+ for (const key of PROVIDER_SETTING_KEYS) delete settings[key];
122
+ settings.backend = Object.prototype.hasOwnProperty.call(settings, "backend")
123
+ ? normalizeProviderId(settings.backend, null)
124
+ : null;
125
+ if (!Object.prototype.hasOwnProperty.call(settings, "compactWindow")) settings.compactWindow = null;
126
+ if (!Object.prototype.hasOwnProperty.call(settings, "recallEngine")) settings.recallEngine = null;
127
+ return settings;
23
128
  }
24
129
 
25
130
  function mergeSavedState(existing, next) {
131
+ const mergedProviders = normalizeProviderSettings(existing.providerSettings);
132
+ for (const [provider, settings] of Object.entries(normalizeProviderSettings(next.providerSettings))) {
133
+ mergedProviders[provider] = { ...(mergedProviders[provider] || providerDefaults()), ...settings };
134
+ }
135
+ const mergedSessions = normalizeActiveSessions(existing.activeSessions);
136
+ for (const [project, providers] of Object.entries(normalizeActiveSessions(next.activeSessions))) {
137
+ mergedSessions[project] = { ...(mergedSessions[project] || {}), ...providers };
138
+ }
26
139
  return {
27
140
  ...existing,
28
141
  ...next,
29
- settings: { ...(existing.settings || {}), ...(next.settings || {}) },
30
- sessionUsage: { ...(existing.sessionUsage || {}), ...(next.sessionUsage || {}) },
142
+ settings: { ...commonSettingsFrom(existing.settings), ...commonSettingsFrom(next.settings) },
143
+ activeSessions: mergedSessions,
144
+ providerSettings: mergedProviders,
145
+ usageBySession: boundUsageBySession({
146
+ ...cloneRecord(existing.usageBySession),
147
+ ...cloneRecord(next.usageBySession),
148
+ }),
149
+ legacyProviderState: {
150
+ ...cloneRecord(existing.legacyProviderState),
151
+ ...cloneRecord(next.legacyProviderState),
152
+ archivedSessionPointers: {
153
+ ...cloneRecord(existing.legacyProviderState?.archivedSessionPointers),
154
+ ...cloneRecord(next.legacyProviderState?.archivedSessionPointers),
155
+ },
156
+ unresolvedSessionPointers: {
157
+ ...cloneRecord(existing.legacyProviderState?.unresolvedSessionPointers),
158
+ ...cloneRecord(next.legacyProviderState?.unresolvedSessionPointers),
159
+ },
160
+ archivedActiveSessions: {
161
+ ...cloneRecord(existing.legacyProviderState?.archivedActiveSessions),
162
+ ...cloneRecord(next.legacyProviderState?.archivedActiveSessions),
163
+ },
164
+ archivedProviderSettings: {
165
+ ...cloneRecord(existing.legacyProviderState?.archivedProviderSettings),
166
+ ...cloneRecord(next.legacyProviderState?.archivedProviderSettings),
167
+ },
168
+ unresolvedCodexUsageSnapshots: {
169
+ ...cloneRecord(existing.legacyProviderState?.unresolvedCodexUsageSnapshots),
170
+ ...cloneRecord(next.legacyProviderState?.unresolvedCodexUsageSnapshots),
171
+ },
172
+ },
31
173
  };
32
174
  }
33
175
 
176
+ function migrateSavedUser(value) {
177
+ const source = cloneRecord(value);
178
+ const sourceSettings = cloneRecord(source.settings);
179
+ const settings = commonSettingsFrom(source.settings);
180
+ const removedSelection = String(sourceSettings.backend || "").toLowerCase() === "cursor";
181
+ if (removedSelection) settings.backend = null;
182
+ const provider = normalizeProviderId(settings.backend, null);
183
+ const providerSettings = normalizeProviderSettings(source.providerSettings);
184
+ if (provider) {
185
+ if (!providerSettings[provider]) providerSettings[provider] = providerDefaults();
186
+ for (const key of PROVIDER_SETTING_KEYS) {
187
+ if (Object.prototype.hasOwnProperty.call(sourceSettings, key)) {
188
+ providerSettings[provider][key] = sourceSettings[key];
189
+ }
190
+ }
191
+ }
192
+
193
+ const activeSessions = normalizeActiveSessions(source.activeSessions);
194
+ const project = projectNameFrom(source.currentSession);
195
+ const legacy = cloneRecord(source.legacyProviderState);
196
+ const archivedSessionPointers = cloneRecord(legacy.archivedSessionPointers);
197
+ const unresolvedSessionPointers = cloneRecord(legacy.unresolvedSessionPointers);
198
+ const archivedActiveSessions = cloneRecord(legacy.archivedActiveSessions);
199
+ const archivedProviderSettings = cloneRecord(legacy.archivedProviderSettings);
200
+ for (const [projectName, providers] of Object.entries(cloneRecord(source.activeSessions))) {
201
+ if (!isRecord(providers) || typeof providers.cursor !== "string" || !providers.cursor) continue;
202
+ archivedActiveSessions[projectName] = {
203
+ ...cloneRecord(archivedActiveSessions[projectName]),
204
+ cursor: providers.cursor,
205
+ };
206
+ }
207
+ if (isRecord(source.providerSettings?.cursor)) {
208
+ archivedProviderSettings.cursor = {
209
+ ...cloneRecord(archivedProviderSettings.cursor),
210
+ ...cloneRecord(source.providerSettings.cursor),
211
+ };
212
+ }
213
+ if (removedSelection) {
214
+ archivedProviderSettings.cursor = {
215
+ ...providerDefaults(),
216
+ ...cloneRecord(archivedProviderSettings.cursor),
217
+ };
218
+ for (const key of PROVIDER_SETTING_KEYS) {
219
+ if (Object.prototype.hasOwnProperty.call(sourceSettings, key)) archivedProviderSettings.cursor[key] = sourceSettings[key];
220
+ }
221
+ }
222
+ let legacyCodexPointerUnresolved = false;
223
+ let legacyCodexPointerProject = null;
224
+ for (const [legacyKey, providerId] of Object.entries(LEGACY_POINTERS)) {
225
+ const sessionId = source[legacyKey];
226
+ if (typeof sessionId !== "string" || !sessionId) continue;
227
+ archivedSessionPointers[providerId] = sessionId;
228
+ if (providerId === "codex") {
229
+ // The legacy Codex pointer was process-global rather than project-bound.
230
+ // Only retain it as active when an already provider-aware bucket proves
231
+ // exactly one project owns the same thread; currentSession is not proof.
232
+ const owners = Object.entries(activeSessions)
233
+ .filter(([, providers]) => providers.codex === sessionId)
234
+ .map(([projectName]) => projectName);
235
+ if (owners.length !== 1) {
236
+ unresolvedSessionPointers.codex = sessionId;
237
+ legacyCodexPointerUnresolved = true;
238
+ } else legacyCodexPointerProject = owners[0];
239
+ continue;
240
+ }
241
+ if (project) {
242
+ activeSessions[project] = { ...(activeSessions[project] || {}) };
243
+ if (!activeSessions[project][providerId]) activeSessions[project][providerId] = sessionId;
244
+ } else {
245
+ unresolvedSessionPointers[providerId] = sessionId;
246
+ }
247
+ }
248
+ for (const [legacyKey, providerId] of Object.entries(REMOVED_PROVIDER_POINTERS)) {
249
+ const sessionId = source[legacyKey];
250
+ if (typeof sessionId !== "string" || !sessionId) continue;
251
+ archivedSessionPointers[providerId] = sessionId;
252
+ if (project) {
253
+ archivedActiveSessions[project] = {
254
+ ...cloneRecord(archivedActiveSessions[project]),
255
+ [providerId]: sessionId,
256
+ };
257
+ } else {
258
+ unresolvedSessionPointers[providerId] = sessionId;
259
+ }
260
+ }
261
+
262
+ const usageBySession = boundUsageBySession(source.usageBySession);
263
+ if (isRecord(source.sessionUsage)) {
264
+ const key = provider === "codex" && typeof source.codexSessionId === "string" && source.codexSessionId
265
+ ? (legacyCodexPointerUnresolved
266
+ ? null
267
+ : usageSessionKey(legacyCodexPointerProject, "codex", source.codexSessionId))
268
+ : usageSessionKey(project, provider, project ? activeSessions[project]?.[provider] : null);
269
+ if (key && !usageBySession[key]) usageBySession[key] = { ...freshUsage(), ...source.sessionUsage };
270
+ else if (!key && !legacy.unresolvedUsage) legacy.unresolvedUsage = { ...source.sessionUsage };
271
+ }
272
+ const legacyCodexSnapshots = cloneRecord(source.codexUsageSnapshots);
273
+ for (const [sessionId, snapshot] of Object.entries(legacyCodexSnapshots)) {
274
+ if (!isRecord(snapshot)) continue;
275
+ const owners = Object.entries(activeSessions)
276
+ .filter(([, providers]) => providers.codex === sessionId)
277
+ .map(([projectName]) => projectName);
278
+ if (owners.length === 1) {
279
+ const key = usageSessionKey(owners[0], "codex", sessionId);
280
+ usageBySession[key] = {
281
+ ...freshUsage(),
282
+ ...cloneRecord(usageBySession[key]),
283
+ providerUsageSnapshot: { ...snapshot },
284
+ };
285
+ } else {
286
+ legacy.unresolvedCodexUsageSnapshots = {
287
+ ...cloneRecord(legacy.unresolvedCodexUsageSnapshots),
288
+ [sessionId]: { ...snapshot },
289
+ };
290
+ }
291
+ }
292
+
293
+ const result = {
294
+ ...source,
295
+ currentSession: isRecord(source.currentSession) ? { ...source.currentSession } : null,
296
+ settings,
297
+ activeSessions,
298
+ providerSettings,
299
+ usageBySession: boundUsageBySession(usageBySession),
300
+ legacyProviderState: {
301
+ ...legacy,
302
+ ...(Object.keys(archivedSessionPointers).length ? { archivedSessionPointers } : {}),
303
+ ...(Object.keys(unresolvedSessionPointers).length ? { unresolvedSessionPointers } : {}),
304
+ ...(Object.keys(archivedActiveSessions).length ? { archivedActiveSessions } : {}),
305
+ ...(Object.keys(archivedProviderSettings).length ? { archivedProviderSettings } : {}),
306
+ ...((removedSelection || legacy.providerSelectionRequired) ? {
307
+ archivedBackend: legacy.archivedBackend || "cursor",
308
+ providerSelectionRequired: true,
309
+ } : {}),
310
+ },
311
+ lastCompactedAt: Number.isFinite(source.lastCompactedAt) ? source.lastCompactedAt : 0,
312
+ };
313
+ for (const legacyKey of Object.keys(LEGACY_POINTERS)) delete result[legacyKey];
314
+ for (const legacyKey of Object.keys(REMOVED_PROVIDER_POINTERS)) delete result[legacyKey];
315
+ delete result.sessionUsage;
316
+ delete result.codexUsageSnapshots;
317
+ if (!Object.keys(result.legacyProviderState).length) delete result.legacyProviderState;
318
+ return result;
319
+ }
320
+
321
+ function migrateStateDocument(raw, options = {}) {
322
+ const source = isRecord(raw) ? raw : {};
323
+ if (Number.isFinite(source.schemaVersion) && source.schemaVersion > STATE_SCHEMA_VERSION) {
324
+ const error = new Error(`unsupported state schema version: ${source.schemaVersion}`);
325
+ error.code = "UNSUPPORTED_STATE_SCHEMA";
326
+ throw error;
327
+ }
328
+ const defaultUserId = String(options.defaultUserId || "telegram:");
329
+ const canonicalize = typeof options.canonicalizeUserId === "function"
330
+ ? options.canonicalizeUserId
331
+ : (value) => String(value);
332
+ const sourceUsers = isRecord(source.users) ? source.users : { [defaultUserId]: source };
333
+ const users = {};
334
+ for (const [key, value] of Object.entries(sourceUsers)) {
335
+ const userId = String(canonicalize(key) || key);
336
+ const migrated = migrateSavedUser(value);
337
+ users[userId] = users[userId] ? mergeSavedState(users[userId], migrated) : migrated;
338
+ }
339
+ return { schemaVersion: STATE_SCHEMA_VERSION, users };
340
+ }
341
+
342
+ function loadStateFile() {
343
+ return readJsonWithFallback(STATE_FILE, {});
344
+ }
345
+
34
346
  // Multi-user state. Legacy top-level shapes are migrated to
35
347
  // `{ users: { "<canonicalId>": {...} } }` on load.
348
+ const hadPersistedStateFile = fs.existsSync(STATE_FILE);
36
349
  const savedState = (() => {
37
350
  const raw = loadStateFile();
38
- const users = {};
39
- if (raw && raw.users && typeof raw.users === "object") {
40
- for (const [key, value] of Object.entries(raw.users)) {
41
- const userId = canonicalForStoredUserKey(key);
42
- users[userId] = mergeSavedState(users[userId] || {}, value || {});
43
- }
44
- return { users };
45
- }
46
- return { users: { [canonicalForTelegram(CHAT_ID || "")]: raw || {} } };
351
+ return migrateStateDocument(raw, {
352
+ defaultUserId: canonicalForTelegram(CHAT_ID || ""),
353
+ canonicalizeUserId: canonicalForStoredUserKey,
354
+ });
47
355
  })();
48
356
 
49
357
  const userStates = new Map();
50
358
 
51
- function freshSettings() {
52
- return { model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude", compactWindow: null, recallEngine: null };
359
+ let cachedFreshDefaultProvider;
360
+ function freshDefaultProvider() {
361
+ if (cachedFreshDefaultProvider !== undefined) return cachedFreshDefaultProvider;
362
+ try {
363
+ cachedFreshDefaultProvider = require("./providers").selectDefaultProvider().providerId;
364
+ } catch (_) {
365
+ cachedFreshDefaultProvider = null;
366
+ }
367
+ return cachedFreshDefaultProvider;
53
368
  }
54
369
 
55
- function freshUsage() {
56
- return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0, liveContextTokens: 0, lastUsageScope: "turn_delta" };
370
+ function freshSettings(defaultProvider = null) {
371
+ return {
372
+ model: null,
373
+ effort: null,
374
+ budget: null,
375
+ permissionMode: null,
376
+ worktree: false,
377
+ backend: defaultProvider,
378
+ compactWindow: null,
379
+ recallEngine: null,
380
+ };
381
+ }
382
+
383
+ function getActiveProvider(state) {
384
+ return normalizeProviderId(state?.settings?.backend, null);
385
+ }
386
+
387
+ function getProjectKey(state, projectName) {
388
+ return projectNameFrom(projectName === undefined ? state?.currentSession : projectName);
389
+ }
390
+
391
+ function getProviderSession(state, providerId, projectName) {
392
+ const project = getProjectKey(state, projectName);
393
+ const provider = normalizeProviderId(providerId, null);
394
+ if (!state || !project || !provider) return null;
395
+ const value = state.activeSessions?.[project]?.[provider];
396
+ return typeof value === "string" && value ? value : null;
397
+ }
398
+
399
+ function setProviderSession(state, providerId, projectName, sessionId) {
400
+ const project = getProjectKey(state, projectName);
401
+ const provider = normalizeProviderId(providerId, null);
402
+ if (!state || !project || !provider || typeof sessionId !== "string" || !sessionId) {
403
+ throw new TypeError("provider, project, and sessionId are required");
404
+ }
405
+ if (!isRecord(state.activeSessions)) state.activeSessions = {};
406
+ state.activeSessions[project] = { ...cloneRecord(state.activeSessions[project]), [provider]: sessionId };
407
+ return sessionId;
408
+ }
409
+
410
+ function clearProviderSession(state, providerId, projectName) {
411
+ const project = getProjectKey(state, projectName);
412
+ const provider = normalizeProviderId(providerId, null);
413
+ if (!state || !project || !provider || !isRecord(state.activeSessions?.[project])) return false;
414
+ const bucket = { ...state.activeSessions[project] };
415
+ if (!Object.prototype.hasOwnProperty.call(bucket, provider)) return false;
416
+ delete bucket[provider];
417
+ if (Object.keys(bucket).length) state.activeSessions[project] = bucket;
418
+ else delete state.activeSessions[project];
419
+ return true;
420
+ }
421
+
422
+ function getProviderSettings(state, providerId) {
423
+ if (!state || typeof state !== "object") throw new TypeError("state is required");
424
+ const provider = normalizeProviderId(providerId || getActiveProvider(state), null);
425
+ if (!provider) {
426
+ const error = new Error("Choose Claude Code or OpenAI Codex before changing provider settings");
427
+ error.code = "NO_ACTIVE_PROVIDER";
428
+ throw error;
429
+ }
430
+ if (!isRecord(state.providerSettings)) state.providerSettings = {};
431
+ if (!isRecord(state.providerSettings[provider])) state.providerSettings[provider] = providerDefaults();
432
+ else state.providerSettings[provider] = { ...providerDefaults(), ...state.providerSettings[provider] };
433
+ return state.providerSettings[provider];
434
+ }
435
+
436
+ function getActiveUsage(state) {
437
+ if (!state || typeof state !== "object") throw new TypeError("state is required");
438
+ const project = getProjectKey(state);
439
+ if (!project) {
440
+ if (!isRecord(state.unscopedUsage)) state.unscopedUsage = freshUsage();
441
+ return state.unscopedUsage;
442
+ }
443
+ const provider = getActiveProvider(state);
444
+ if (!provider) {
445
+ if (!isRecord(state.unscopedUsage)) state.unscopedUsage = freshUsage();
446
+ return state.unscopedUsage;
447
+ }
448
+ return getUsageForSession(state, project, provider, getProviderSession(state, provider, project));
449
+ }
450
+
451
+ function getUsageForSession(state, projectName, providerId, sessionId) {
452
+ if (!state || typeof state !== "object") throw new TypeError("state is required");
453
+ const project = getProjectKey(state, projectName);
454
+ const provider = normalizeProviderId(providerId, null);
455
+ if (!project || !provider) throw new TypeError("project and provider are required for usage");
456
+ const key = usageSessionKey(project, provider, sessionId);
457
+ if (!isRecord(state.usageBySession)) state.usageBySession = {};
458
+ const usage = isRecord(state.usageBySession[key]) ? state.usageBySession[key] : freshUsage();
459
+ delete state.usageBySession[key];
460
+ state.usageBySession[key] = usage;
461
+ state.usageBySession = boundUsageBySession(state.usageBySession);
462
+ return state.usageBySession[key];
463
+ }
464
+
465
+ function attachRuntimeAccessors(state) {
466
+ for (const key of PROVIDER_SETTING_KEYS) {
467
+ Object.defineProperty(state.settings, key, {
468
+ configurable: true,
469
+ enumerable: true,
470
+ get() {
471
+ const provider = getActiveProvider(state);
472
+ return provider ? getProviderSettings(state, provider)[key] : providerDefaults()[key];
473
+ },
474
+ set(value) {
475
+ const provider = getActiveProvider(state);
476
+ if (!provider) throw Object.assign(new Error("Choose a provider first"), { code: "NO_ACTIVE_PROVIDER" });
477
+ getProviderSettings(state, provider)[key] = value;
478
+ },
479
+ });
480
+ }
481
+ Object.defineProperty(state, "sessionUsage", {
482
+ configurable: true,
483
+ enumerable: true,
484
+ get() { return getActiveUsage(state); },
485
+ set(value) {
486
+ const project = getProjectKey(state);
487
+ if (!project) { state.unscopedUsage = { ...freshUsage(), ...cloneRecord(value) }; return; }
488
+ const provider = getActiveProvider(state);
489
+ if (!provider) { state.unscopedUsage = { ...freshUsage(), ...cloneRecord(value) }; return; }
490
+ const key = usageSessionKey(project, provider, getProviderSession(state, provider, project));
491
+ delete state.usageBySession[key];
492
+ state.usageBySession[key] = { ...freshUsage(), ...cloneRecord(value) };
493
+ state.usageBySession = boundUsageBySession(state.usageBySession);
494
+ },
495
+ });
496
+ return state;
57
497
  }
58
498
 
59
499
  function createUserState(userId) {
60
500
  const saved = (savedState.users && savedState.users[userId]) || {};
61
- const settings = saved.settings || freshSettings();
62
- if (!settings.backend) settings.backend = "claude";
63
- return {
501
+ const hasSavedUser = hadPersistedStateFile
502
+ && !!savedState.users
503
+ && Object.prototype.hasOwnProperty.call(savedState.users, userId);
504
+ const defaults = freshSettings(freshDefaultProvider());
505
+ const settings = commonSettingsFrom(hasSavedUser ? (saved.settings || {}) : defaults);
506
+ if (!hasSavedUser) settings.backend = defaults.backend;
507
+ else if (!settings.backend && !saved.legacyProviderState?.providerSelectionRequired) {
508
+ settings.backend = freshDefaultProvider();
509
+ }
510
+ const state = {
64
511
  userId: String(userId),
65
512
  channelId: currentChannelId(),
66
513
  currentSession: saved.currentSession || null,
@@ -77,15 +524,16 @@ function createUserState(userId) {
77
524
  statusMessageChannel: null,
78
525
  streamBuffer: "",
79
526
  streamInterval: null,
80
- lastSessionId: saved.lastSessionId || null,
81
- cursorSessionId: saved.cursorSessionId || null,
82
- codexSessionId: saved.codexSessionId || null,
83
- codexUsageSnapshots: saved.codexUsageSnapshots || {},
527
+ activeSessions: normalizeActiveSessions(saved.activeSessions),
528
+ providerSettings: normalizeProviderSettings(saved.providerSettings),
529
+ usageBySession: boundUsageBySession(saved.usageBySession),
84
530
  messageQueue: [],
85
- isFirstMessage: !saved.lastSessionId,
531
+ isFirstMessage: true,
86
532
  lastInputWasVoice: false,
87
- sessionUsage: saved.sessionUsage || freshUsage(),
88
533
  settings,
534
+ legacyProviderState: cloneRecord(saved.legacyProviderState),
535
+ pendingCompaction: isRecord(saved.pendingCompaction) ? structuredClone(saved.pendingCompaction) : null,
536
+ completedCompactions: cloneRecord(saved.completedCompactions),
89
537
  onboardingStep: null,
90
538
  onboardingData: {},
91
539
  pendingVaultUnlock: false,
@@ -97,12 +545,23 @@ function createUserState(userId) {
97
545
  isCompacting: false,
98
546
  lastCompactedAt: saved.lastCompactedAt || 0,
99
547
  };
548
+ attachRuntimeAccessors(state);
549
+ state.isFirstMessage = !getProviderSession(state, getActiveProvider(state), getProjectKey(state));
550
+ return state;
100
551
  }
101
552
 
102
553
  function getUserState(userId) {
103
554
  const id = normalizeCanonicalUserId(userId);
104
555
  if (!userStates.has(id)) userStates.set(id, createUserState(id));
105
556
  const state = userStates.get(id);
557
+ if (state.pendingCompaction && !state.compactionRecoveryChecked) {
558
+ state.compactionRecoveryChecked = true;
559
+ try { recoverPendingCompaction(state); }
560
+ catch (error) {
561
+ state.compactionRecoveryChecked = false;
562
+ throw error;
563
+ }
564
+ }
106
565
  state.channelId = currentChannelId() || state.channelId;
107
566
  return state;
108
567
  }
@@ -113,101 +572,618 @@ function currentState() {
113
572
 
114
573
  function resetSessionUsage(state = currentState()) {
115
574
  state.sessionUsage = freshUsage();
116
- state.codexUsageSnapshots = {};
117
575
  }
118
576
 
119
577
  function resetSettings(state = currentState()) {
120
- state.settings = freshSettings();
578
+ state.providerSettings = normalizeProviderSettings({});
579
+ state.settings = commonSettingsFrom(freshSettings(freshDefaultProvider()));
580
+ attachRuntimeAccessors(state);
581
+ }
582
+
583
+ // Synchronous single-flight run-lock. Returns true and arms the lock if no turn
584
+ // is running or preparing for this user; false if the caller must queue. There
585
+ // must be NO await between calling this and the work it guards, so two turns for
586
+ // one canonical user (owner's Telegram + Kazee sharing one state under unified
587
+ // identity) can never both acquire and then race the shared state + JSON files.
588
+ // Compaction owns the same admission boundary across its summary/seed gap.
589
+ function acquireRunLock(state = currentState()) {
590
+ if (state.runningProcess || state.preparingRun || state.isCompacting) return false;
591
+ state.preparingRun = true;
592
+ return true;
121
593
  }
122
594
 
123
- function saveState() {
124
- const data = { users: { ...(savedState.users || {}) } };
595
+ function migrationSources() {
596
+ return defaultMigrationSources({
597
+ configDir: CONFIG_DIR,
598
+ stateFile: STATE_FILE,
599
+ sessionsFile: SESSIONS_FILE,
600
+ jobsFile: JOBS_FILE,
601
+ cronsFile: CRONS_FILE,
602
+ });
603
+ }
604
+
605
+ function serializeCommonSettings(settings) {
606
+ const result = {};
607
+ for (const [key, value] of Object.entries(settings || {})) {
608
+ if (!PROVIDER_SETTING_KEYS.includes(key)) result[key] = value;
609
+ }
610
+ result.backend = normalizeProviderId(result.backend, null);
611
+ if (!Object.prototype.hasOwnProperty.call(result, "compactWindow")) result.compactWindow = null;
612
+ if (!Object.prototype.hasOwnProperty.call(result, "recallEngine")) result.recallEngine = null;
613
+ return result;
614
+ }
615
+
616
+ function serializeUserState(state, existing = {}) {
617
+ const result = {
618
+ ...existing,
619
+ currentSession: state.currentSession,
620
+ activeSessions: normalizeActiveSessions(state.activeSessions),
621
+ providerSettings: normalizeProviderSettings(state.providerSettings),
622
+ usageBySession: boundUsageBySession(state.usageBySession),
623
+ settings: serializeCommonSettings(state.settings),
624
+ lastCompactedAt: state.lastCompactedAt || 0,
625
+ pendingCompaction: isRecord(state.pendingCompaction) ? structuredClone(state.pendingCompaction) : null,
626
+ completedCompactions: Object.fromEntries(Object.entries(cloneRecord(state.completedCompactions)).slice(-20)),
627
+ };
628
+ if (Object.keys(cloneRecord(state.legacyProviderState)).length) result.legacyProviderState = cloneRecord(state.legacyProviderState);
629
+ else delete result.legacyProviderState;
630
+ for (const legacyKey of Object.keys(LEGACY_POINTERS)) delete result[legacyKey];
631
+ for (const legacyKey of Object.keys(REMOVED_PROVIDER_POINTERS)) delete result[legacyKey];
632
+ delete result.sessionUsage;
633
+ return result;
634
+ }
635
+
636
+ function saveState(options = {}) {
637
+ const sources = migrationSources();
638
+ let snapshot;
639
+ try {
640
+ const disk = loadStateFile();
641
+ const alreadyProviderAware = Number.isFinite(disk?.schemaVersion)
642
+ && disk.schemaVersion >= 2
643
+ && disk.schemaVersion <= STATE_SCHEMA_VERSION
644
+ && isRecord(disk.users);
645
+ snapshot = requireMigrationSnapshotForComponent("state", {
646
+ configDir: CONFIG_DIR,
647
+ sources,
648
+ allowSourceMismatch: alreadyProviderAware,
649
+ });
650
+ markMigrationComponentPrepared(snapshot.snapshotDir, "state", {
651
+ configDir: CONFIG_DIR,
652
+ sources,
653
+ requireSourceMatch: !alreadyProviderAware,
654
+ });
655
+ } catch (e) {
656
+ console.error("saveState: migration preflight failed:", e.message);
657
+ if (options.strict) throw e;
658
+ return false;
659
+ }
660
+
661
+ const data = { schemaVersion: STATE_SCHEMA_VERSION, users: { ...(savedState.users || {}) } };
125
662
  for (const [id, s] of userStates) {
126
- data.users[id] = {
127
- currentSession: s.currentSession,
128
- lastSessionId: s.lastSessionId,
129
- cursorSessionId: s.cursorSessionId,
130
- codexSessionId: s.codexSessionId,
131
- codexUsageSnapshots: s.codexUsageSnapshots || {},
132
- settings: s.settings,
133
- sessionUsage: s.sessionUsage,
134
- lastCompactedAt: s.lastCompactedAt || 0,
135
- };
663
+ data.users[id] = serializeUserState(s, data.users[id]);
664
+ }
665
+ try {
666
+ atomicWriteFileSync(STATE_FILE, JSON.stringify(data), { backup: true });
667
+ if (typeof options.faultInjector === "function") options.faultInjector("after-state-write", { stateFile: STATE_FILE });
668
+ const persisted = readJsonWithFallback(STATE_FILE, null);
669
+ if (persisted?.schemaVersion !== STATE_SCHEMA_VERSION || !isRecord(persisted.users)) {
670
+ const error = new Error("provider-aware state verification failed after write");
671
+ error.code = "STATE_MIGRATION_WRITE_INVALID";
672
+ throw error;
673
+ }
674
+ markMigrationComponentActivated(snapshot.snapshotDir, "state", { configDir: CONFIG_DIR, sources });
675
+ savedState.schemaVersion = STATE_SCHEMA_VERSION;
676
+ savedState.users = data.users;
677
+ }
678
+ catch (e) {
679
+ console.error("saveState: write failed:", e.message);
680
+ if (options.strict) throw e;
681
+ return false;
136
682
  }
137
- savedState.users = data.users;
138
- try { fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch (e) {}
683
+ return true;
139
684
  }
140
685
 
141
686
  // ── Sessions ────────────────────────────────────────────────────────
142
687
 
143
- function loadSessions() {
144
- let raw;
145
- try { raw = JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return {}; }
146
- if (!raw || typeof raw !== "object") return {};
147
- const looksLegacy = Object.values(raw).some((v) => Array.isArray(v));
148
- if (looksLegacy) return { [canonicalForTelegram(CHAT_ID || "")]: raw };
149
- const migrated = {};
150
- for (const [key, value] of Object.entries(raw)) {
151
- const userId = canonicalForStoredUserKey(key);
152
- migrated[userId] = { ...(migrated[userId] || {}) };
153
- for (const [project, list] of Object.entries(value || {})) {
154
- migrated[userId][project] = [
155
- ...(migrated[userId][project] || []),
156
- ...(Array.isArray(list) ? list : []),
157
- ].slice(-20);
688
+ function sessionTimestamp(value) {
689
+ const parsed = Date.parse(value || "");
690
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString();
691
+ }
692
+
693
+ function sessionSortValue(record) {
694
+ return Date.parse(record?.lastUsed || record?.created || "") || 0;
695
+ }
696
+
697
+ function normalizeSessionRecord(record, context, options = {}) {
698
+ if (!isRecord(record) || typeof record.id !== "string" || !record.id) return null;
699
+ const declaredProvider = String(record.provider || "").trim().toLowerCase();
700
+ if (declaredProvider === "cursor" || record.archivedProvider === "cursor") {
701
+ const created = sessionTimestamp(record.created);
702
+ const lastUsed = sessionTimestamp(record.lastUsed || record.created);
703
+ return {
704
+ ...record,
705
+ id: record.id,
706
+ provider: "legacy",
707
+ archivedProvider: "cursor",
708
+ project: context.project,
709
+ title: typeof record.title === "string" && record.title ? record.title : "Untitled",
710
+ created,
711
+ lastUsed,
712
+ selectable: false,
713
+ legacyResolution: "removed-provider",
714
+ };
715
+ }
716
+ let provider = normalizeProviderId(record.provider, null);
717
+ let selectable = record.selectable !== false;
718
+ let legacyResolution = record.legacyResolution || null;
719
+ if (declaredProvider === "legacy") {
720
+ provider = "legacy";
721
+ selectable = false;
722
+ } else if (!SELECTABLE_SESSION_PROVIDERS.has(provider)) {
723
+ provider = null;
724
+ if (typeof options.ownsSession === "function") {
725
+ const matches = [];
726
+ for (const candidate of ["claude", "codex"]) {
727
+ try {
728
+ if (options.ownsSession(candidate, { ...context, sessionId: record.id }) === true) matches.push(candidate);
729
+ } catch (_) { /* a read-only ownership probe is advisory */ }
730
+ }
731
+ if (matches.length === 1) provider = matches[0];
732
+ else legacyResolution = matches.length > 1 ? "ambiguous-provider" : "unclassified";
733
+ } else {
734
+ legacyResolution = "no-provider-probe";
158
735
  }
159
736
  }
160
- return migrated;
737
+ if (!provider) provider = "legacy";
738
+ if (provider === "legacy") selectable = false;
739
+ const created = sessionTimestamp(record.created);
740
+ const lastUsed = sessionTimestamp(record.lastUsed || record.created);
741
+ return {
742
+ ...record,
743
+ id: record.id,
744
+ provider,
745
+ project: context.project,
746
+ title: typeof record.title === "string" && record.title ? record.title : "Untitled",
747
+ created,
748
+ lastUsed,
749
+ selectable,
750
+ ...(provider === "legacy" ? { legacyResolution: legacyResolution || "unclassified" } : {}),
751
+ };
161
752
  }
162
753
 
163
- function saveSessions(sessions) {
164
- try { fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); } catch (e) {}
754
+ function mergeSessionLists(existing, next) {
755
+ const records = new Map();
756
+ for (const record of [...(existing || []), ...(next || [])]) {
757
+ if (!record) continue;
758
+ const key = `${record.provider}\u0000${record.id}`;
759
+ const previous = records.get(key);
760
+ if (!previous || sessionSortValue(record) >= sessionSortValue(previous)) records.set(key, record);
761
+ }
762
+ return [...records.values()]
763
+ .sort((left, right) => sessionSortValue(left) - sessionSortValue(right))
764
+ .slice(-MAX_SESSIONS_PER_PROJECT);
165
765
  }
166
766
 
167
- function recordSession(userId, projectName, sessionId, title) {
168
- const all = loadSessions();
169
- const id = normalizeCanonicalUserId(userId);
170
- if (!all[id]) all[id] = {};
171
- if (!all[id][projectName]) all[id][projectName] = [];
172
- const arr = all[id][projectName];
173
- const existing = arr.find((s) => s.id === sessionId);
174
- if (existing) {
175
- if (title) existing.title = title;
176
- existing.lastUsed = new Date().toISOString();
177
- } else {
178
- arr.push({
179
- id: sessionId,
180
- title: title || "Untitled",
181
- created: new Date().toISOString(),
182
- lastUsed: new Date().toISOString(),
767
+ function migrateSessionsDocument(raw, options = {}) {
768
+ const source = isRecord(raw) ? raw : {};
769
+ if (Number.isFinite(source.schemaVersion) && source.schemaVersion > SESSIONS_SCHEMA_VERSION) {
770
+ const error = new Error(`unsupported sessions schema version: ${source.schemaVersion}`);
771
+ error.code = "UNSUPPORTED_SESSIONS_SCHEMA";
772
+ throw error;
773
+ }
774
+ const defaultUserId = String(options.defaultUserId || canonicalForTelegram(CHAT_ID || ""));
775
+ const canonicalize = typeof options.canonicalizeUserId === "function"
776
+ ? options.canonicalizeUserId
777
+ : canonicalForStoredUserKey;
778
+ const explicitUsers = isRecord(source.users) && (!Number.isFinite(source.schemaVersion) || source.schemaVersion <= SESSIONS_SCHEMA_VERSION);
779
+ const looksProjectRoot = !explicitUsers && Object.values(source).some((value) => Array.isArray(value));
780
+ const sourceUsers = explicitUsers ? source.users : (looksProjectRoot ? { [defaultUserId]: source } : source);
781
+ const users = {};
782
+ for (const [userKey, projects] of Object.entries(cloneRecord(sourceUsers))) {
783
+ const userId = String(canonicalize(userKey) || userKey);
784
+ if (!users[userId]) users[userId] = {};
785
+ for (const [projectKey, list] of Object.entries(cloneRecord(projects))) {
786
+ if (!Array.isArray(list)) continue;
787
+ const project = String(projectKey || "").trim();
788
+ if (!project) continue;
789
+ const normalized = list
790
+ .map((record) => normalizeSessionRecord(record, { userId, project }, options))
791
+ .filter(Boolean);
792
+ users[userId][project] = mergeSessionLists(users[userId][project], normalized);
793
+ }
794
+ }
795
+ return { schemaVersion: SESSIONS_SCHEMA_VERSION, users };
796
+ }
797
+
798
+ function loadSessionsDocument(options = {}) {
799
+ const raw = readJsonWithFallback(SESSIONS_FILE, {});
800
+ return migrateSessionsDocument(raw, {
801
+ defaultUserId: canonicalForTelegram(CHAT_ID || ""),
802
+ canonicalizeUserId: canonicalForStoredUserKey,
803
+ ownsSession: options.ownsSession,
804
+ });
805
+ }
806
+
807
+ function loadSessions(options = {}) {
808
+ return loadSessionsDocument(options).users;
809
+ }
810
+
811
+ function saveSessions(sessions, options = {}) {
812
+ const document = migrateSessionsDocument(
813
+ isRecord(sessions?.users)
814
+ ? sessions
815
+ : { schemaVersion: SESSIONS_SCHEMA_VERSION, users: sessions || {} },
816
+ { canonicalizeUserId: canonicalForStoredUserKey },
817
+ );
818
+ const sources = migrationSources();
819
+ let snapshot;
820
+ try {
821
+ const disk = readJsonWithFallback(SESSIONS_FILE, {});
822
+ const alreadyProviderAware = Number.isFinite(disk?.schemaVersion)
823
+ && disk.schemaVersion >= 2
824
+ && disk.schemaVersion <= SESSIONS_SCHEMA_VERSION
825
+ && isRecord(disk.users);
826
+ snapshot = requireMigrationSnapshotForComponent("sessions", {
827
+ configDir: CONFIG_DIR,
828
+ sources,
829
+ allowSourceMismatch: alreadyProviderAware,
830
+ });
831
+ markMigrationComponentPrepared(snapshot.snapshotDir, "sessions", {
832
+ configDir: CONFIG_DIR,
833
+ sources,
834
+ requireSourceMatch: !alreadyProviderAware,
183
835
  });
836
+ atomicWriteFileSync(SESSIONS_FILE, JSON.stringify(document, null, 2), { backup: true });
837
+ if (typeof options.faultInjector === "function") options.faultInjector("after-sessions-write", { sessionsFile: SESSIONS_FILE });
838
+ const persisted = JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf8"));
839
+ if (persisted?.schemaVersion !== SESSIONS_SCHEMA_VERSION || !isRecord(persisted.users)) {
840
+ const error = new Error("provider-aware sessions verification failed after write");
841
+ error.code = "SESSIONS_MIGRATION_WRITE_INVALID";
842
+ throw error;
843
+ }
844
+ markMigrationComponentActivated(snapshot.snapshotDir, "sessions", { configDir: CONFIG_DIR, sources });
845
+ } catch (e) {
846
+ console.error("saveSessions: write failed:", e.message);
847
+ if (options.strict) throw e;
848
+ return false;
184
849
  }
185
- all[id][projectName] = arr.slice(-20);
186
- saveSessions(all);
850
+ return true;
187
851
  }
188
852
 
189
- function getProjectSessions(userId, projectName) {
853
+ function requiredSessionIdentity(userId, projectName, providerId, sessionId) {
854
+ const user = normalizeCanonicalUserId(userId);
855
+ const project = projectNameFrom(projectName);
856
+ const provider = normalizeProviderId(providerId, null);
857
+ if (!user || !project || !SELECTABLE_SESSION_PROVIDERS.has(provider) || typeof sessionId !== "string" || !sessionId) {
858
+ throw new TypeError("canonical user, project, provider, and sessionId are required");
859
+ }
860
+ return { userId: user, project, provider, sessionId };
861
+ }
862
+
863
+ function recordSession(userId, projectName, providerId, sessionId, title, options = {}) {
864
+ const identity = requiredSessionIdentity(userId, projectName, providerId, sessionId);
190
865
  const all = loadSessions();
191
- return ((all[normalizeCanonicalUserId(userId)] || {})[projectName] || []).slice().reverse();
866
+ if (!all[identity.userId]) all[identity.userId] = {};
867
+ const current = Array.isArray(all[identity.userId][identity.project])
868
+ ? [...all[identity.userId][identity.project]]
869
+ : [];
870
+ const existingIndex = current.findIndex((record) => (
871
+ record?.id === identity.sessionId && record?.provider === identity.provider
872
+ ));
873
+ const previous = existingIndex >= 0 ? current.splice(existingIndex, 1)[0] : null;
874
+ const nowValue = typeof options.now === "function" ? options.now() : new Date();
875
+ const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
876
+ if (!Number.isFinite(now.getTime())) throw new TypeError("session clock returned an invalid date");
877
+ const record = {
878
+ ...(previous || {}),
879
+ id: identity.sessionId,
880
+ provider: identity.provider,
881
+ project: identity.project,
882
+ title: title || previous?.title || "Untitled",
883
+ created: previous?.created || now.toISOString(),
884
+ lastUsed: now.toISOString(),
885
+ selectable: true,
886
+ ...(options.lineage ? { lineage: structuredClone(options.lineage) } : {}),
887
+ };
888
+ delete record.legacyResolution;
889
+ current.push(record);
890
+ all[identity.userId][identity.project] = mergeSessionLists([], current);
891
+ if (!saveSessions(all, options)) return null;
892
+ return record;
192
893
  }
193
894
 
194
- function getLastProjectSession(userId, projectName) {
195
- const sessions = getProjectSessions(userId, projectName);
196
- return sessions.length > 0 ? sessions[0] : null;
895
+ function replaceProjectSessions(userId, projectName, records, options = {}) {
896
+ const user = normalizeCanonicalUserId(userId);
897
+ const project = projectNameFrom(projectName);
898
+ if (!user || !project || !Array.isArray(records)) {
899
+ throw new TypeError("canonical user, project, and session records are required");
900
+ }
901
+ const all = loadSessions();
902
+ if (!all[user]) all[user] = {};
903
+ all[user][project] = mergeSessionLists([], records.map((record) => (
904
+ normalizeSessionRecord(record, { userId: user, project }, options)
905
+ )).filter(Boolean));
906
+ if (!saveSessions(all, options)) return false;
907
+ return true;
197
908
  }
198
909
 
199
- // Cross-user leakage guard: only resume a Claude session id we have
200
- // recorded for this user. Stops migration glitches from handing one
201
- // person's conversation to another.
202
- function userOwnsClaudeSession(userId, sessionId) {
203
- if (!sessionId) return false;
910
+ function getProjectSessions(userId, projectName, providerId = null) {
911
+ const user = normalizeCanonicalUserId(userId);
912
+ const project = projectNameFrom(projectName);
913
+ if (!user || !project) throw new TypeError("canonical user and project are required");
914
+ const provider = providerId === null || providerId === undefined
915
+ ? null
916
+ : normalizeProviderId(providerId, null);
204
917
  const all = loadSessions();
205
- const bucket = all[normalizeCanonicalUserId(userId)];
206
- if (!bucket) return false;
207
- for (const list of Object.values(bucket)) {
208
- if (Array.isArray(list) && list.some((s) => s && s.id === sessionId)) return true;
918
+ return ((all[user] || {})[project] || [])
919
+ .filter((record) => !provider || record.provider === provider)
920
+ .slice()
921
+ .sort((left, right) => sessionSortValue(right) - sessionSortValue(left));
922
+ }
923
+
924
+ function getLastProjectSession(userId, projectName, providerId = null) {
925
+ const sessions = getProjectSessions(userId, projectName, providerId)
926
+ .filter((record) => record.selectable !== false && record.provider !== "legacy");
927
+ return sessions.length > 0 ? sessions[0] : null;
928
+ }
929
+
930
+ function userOwnsProviderSession(userId, projectName, providerId, sessionId) {
931
+ let identity;
932
+ try { identity = requiredSessionIdentity(userId, projectName, providerId, sessionId); }
933
+ catch (_) { return false; }
934
+ return getProjectSessions(identity.userId, identity.project, identity.provider).some((record) => (
935
+ record.id === identity.sessionId && record.selectable !== false && record.provider !== "legacy"
936
+ ));
937
+ }
938
+
939
+ function purgeSessionCallbackTokens(now = Date.now()) {
940
+ for (const [token, record] of sessionCallbackTokens) {
941
+ if (record.expiresAt <= now) sessionCallbackTokens.delete(token);
942
+ }
943
+ while (sessionCallbackTokens.size >= MAX_SESSION_CALLBACKS) {
944
+ sessionCallbackTokens.delete(sessionCallbackTokens.keys().next().value);
945
+ }
946
+ }
947
+
948
+ function createSessionCallbackToken(descriptor, options = {}) {
949
+ const identity = requiredSessionIdentity(
950
+ descriptor?.userId,
951
+ descriptor?.project,
952
+ descriptor?.provider,
953
+ descriptor?.sessionId,
954
+ );
955
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
956
+ const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0
957
+ ? Math.min(options.ttlMs, 60 * 60 * 1000)
958
+ : SESSION_CALLBACK_TTL_MS;
959
+ const tokenFactory = options.tokenFactory || (() => crypto.randomBytes(18).toString("base64url"));
960
+ purgeSessionCallbackTokens(now);
961
+ for (let attempt = 0; attempt < 10; attempt += 1) {
962
+ const token = String(tokenFactory()).replace(/[^A-Za-z0-9_-]/g, "").slice(0, 48);
963
+ if (!token || sessionCallbackTokens.has(token)) continue;
964
+ sessionCallbackTokens.set(token, { ...identity, expiresAt: now + ttlMs });
965
+ return token;
966
+ }
967
+ throw new Error("could not allocate an opaque session callback token");
968
+ }
969
+
970
+ function consumeSessionCallbackToken(token, expected = {}) {
971
+ const opaque = String(token || "");
972
+ const record = sessionCallbackTokens.get(opaque);
973
+ if (record) sessionCallbackTokens.delete(opaque);
974
+ const now = Number.isFinite(expected.now) ? expected.now : Date.now();
975
+ if (!record || record.expiresAt <= now) return { status: "expired", value: null };
976
+ const userId = normalizeCanonicalUserId(expected.userId);
977
+ const project = projectNameFrom(expected.project);
978
+ const provider = normalizeProviderId(expected.provider, null);
979
+ if (record.userId !== userId || record.project !== project || record.provider !== provider) {
980
+ return { status: "forbidden", value: null };
981
+ }
982
+ return { status: "ok", value: { ...record } };
983
+ }
984
+
985
+ function compactionError(code, message) {
986
+ return Object.assign(new Error(message), { name: "CompactionCommitError", code });
987
+ }
988
+
989
+ function requireCompactionWrite(result, label) {
990
+ if (result === false || result === null) {
991
+ throw compactionError("COMPACTION_PERSIST_FAILED", `${label} did not persist`);
992
+ }
993
+ return result;
994
+ }
995
+
996
+ function validateCompactionTransaction(state, transaction) {
997
+ if (!isRecord(transaction)) throw compactionError("INVALID_COMPACTION", "compaction transaction is required");
998
+ const identity = requiredSessionIdentity(
999
+ transaction.canonicalUserId,
1000
+ transaction.project,
1001
+ transaction.provider,
1002
+ transaction.previousSessionId,
1003
+ );
1004
+ if (identity.userId !== normalizeCanonicalUserId(state.userId)) {
1005
+ throw compactionError("COMPACTION_USER_MISMATCH", "compaction belongs to another user");
1006
+ }
1007
+ if (typeof transaction.operationId !== "string" || !transaction.operationId
1008
+ || typeof transaction.newSessionId !== "string" || !transaction.newSessionId) {
1009
+ throw compactionError("INVALID_COMPACTION", "compaction operation and new session IDs are required");
1010
+ }
1011
+ if (transaction.newSessionId === identity.sessionId) {
1012
+ throw compactionError("INVALID_COMPACTION", "compaction seed did not create a fresh provider session");
1013
+ }
1014
+ return {
1015
+ ...transaction,
1016
+ canonicalUserId: identity.userId,
1017
+ project: identity.project,
1018
+ provider: identity.provider,
1019
+ previousSessionId: identity.sessionId,
1020
+ };
1021
+ }
1022
+
1023
+ function restorePendingCompaction(state, pending, options = {}) {
1024
+ const replaceHistory = options.replaceHistory || replaceProjectSessions;
1025
+ const persistState = options.persistState || saveState;
1026
+ if (pending.previousSessionId) {
1027
+ setProviderSession(state, pending.provider, pending.project, pending.previousSessionId);
1028
+ } else {
1029
+ clearProviderSession(state, pending.provider, pending.project);
1030
+ }
1031
+ if (pending.previousNewUsagePresent) {
1032
+ state.usageBySession[pending.newUsageKey] = structuredClone(pending.previousNewUsage);
1033
+ } else {
1034
+ delete state.usageBySession[pending.newUsageKey];
1035
+ }
1036
+ state.lastCompactedAt = pending.previousLastCompactedAt || 0;
1037
+ state.isFirstMessage = pending.previousIsFirstMessage;
1038
+ if (isRecord(state.completedCompactions)) delete state.completedCompactions[pending.operationId];
1039
+ requireCompactionWrite(
1040
+ replaceHistory(pending.canonicalUserId, pending.project, pending.previousHistory || [], { strict: true }),
1041
+ "compaction history rollback",
1042
+ );
1043
+ state.pendingCompaction = null;
1044
+ requireCompactionWrite(persistState({ strict: true }), "compaction rollback state");
1045
+ return { recovered: true, action: "rolled_back", operationId: pending.operationId };
1046
+ }
1047
+
1048
+ function recoverPendingCompaction(state, options = {}) {
1049
+ const pending = state?.pendingCompaction;
1050
+ if (!isRecord(pending)) return { recovered: false };
1051
+ const persistState = options.persistState || saveState;
1052
+ if (pending.phase === "committed") {
1053
+ setProviderSession(state, pending.provider, pending.project, pending.newSessionId);
1054
+ state.pendingCompaction = null;
1055
+ requireCompactionWrite(persistState({ strict: true }), "compaction recovery state");
1056
+ return { recovered: true, action: "finalized", operationId: pending.operationId };
1057
+ }
1058
+ return restorePendingCompaction(state, pending, options);
1059
+ }
1060
+
1061
+ function commitProviderCompaction(state, transaction, options = {}) {
1062
+ const tx = validateCompactionTransaction(state, transaction);
1063
+ if (state.completedCompactions?.[tx.operationId]) {
1064
+ return {
1065
+ committed: true,
1066
+ duplicate: true,
1067
+ operationId: tx.operationId,
1068
+ sessionId: state.completedCompactions[tx.operationId].newSessionId,
1069
+ };
1070
+ }
1071
+ if (state.pendingCompaction) recoverPendingCompaction(state, options);
1072
+ const current = getProviderSession(state, tx.provider, tx.project);
1073
+ if (current !== tx.previousSessionId) {
1074
+ throw compactionError("COMPACTION_POINTER_CHANGED", "active provider session changed before compaction commit");
1075
+ }
1076
+
1077
+ const persistState = options.persistState || saveState;
1078
+ const recordHistory = options.recordHistory || recordSession;
1079
+ const replaceHistory = options.replaceHistory || replaceProjectSessions;
1080
+ const nowValue = typeof options.now === "function" ? options.now() : new Date();
1081
+ const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
1082
+ if (!Number.isFinite(now.getTime())) throw compactionError("INVALID_COMPACTION", "compaction clock is invalid");
1083
+ const newUsageKey = usageSessionKey(tx.project, tx.provider, tx.newSessionId);
1084
+ const previousNewUsagePresent = Object.prototype.hasOwnProperty.call(state.usageBySession || {}, newUsageKey);
1085
+ const previousHistory = getProjectSessions(tx.canonicalUserId, tx.project);
1086
+ const pending = {
1087
+ phase: "prepared",
1088
+ operationId: tx.operationId,
1089
+ canonicalUserId: tx.canonicalUserId,
1090
+ project: tx.project,
1091
+ provider: tx.provider,
1092
+ previousSessionId: tx.previousSessionId,
1093
+ newSessionId: tx.newSessionId,
1094
+ newUsageKey,
1095
+ previousNewUsagePresent,
1096
+ previousNewUsage: previousNewUsagePresent ? structuredClone(state.usageBySession[newUsageKey]) : null,
1097
+ previousHistory: structuredClone(previousHistory),
1098
+ previousLastCompactedAt: state.lastCompactedAt || 0,
1099
+ previousIsFirstMessage: !!state.isFirstMessage,
1100
+ preparedAt: now.toISOString(),
1101
+ };
1102
+ state.pendingCompaction = pending;
1103
+ let durableCommitted = false;
1104
+ try {
1105
+ requireCompactionWrite(persistState({ strict: true }), "compaction prepared state");
1106
+ if (typeof options.faultInjector === "function") options.faultInjector("after-compaction-prepare", { transaction: tx });
1107
+ const lineage = {
1108
+ type: "compaction",
1109
+ operationId: tx.operationId,
1110
+ previousSessionId: tx.previousSessionId,
1111
+ newSessionId: tx.newSessionId,
1112
+ };
1113
+ requireCompactionWrite(
1114
+ recordHistory(
1115
+ tx.canonicalUserId,
1116
+ tx.project,
1117
+ tx.provider,
1118
+ tx.newSessionId,
1119
+ tx.title || `Compacted ${now.toLocaleDateString()}`,
1120
+ { strict: true, now: () => now, lineage },
1121
+ ),
1122
+ "compaction history",
1123
+ );
1124
+ if (typeof options.faultInjector === "function") options.faultInjector("after-compaction-history", { transaction: tx });
1125
+
1126
+ setProviderSession(state, tx.provider, tx.project, tx.newSessionId);
1127
+ const nextUsage = {
1128
+ ...freshUsage(),
1129
+ lastCompactedAt: now.getTime(),
1130
+ compactionCheckpoint: {
1131
+ operationId: tx.operationId,
1132
+ previousSessionId: tx.previousSessionId,
1133
+ newSessionId: tx.newSessionId,
1134
+ at: now.toISOString(),
1135
+ },
1136
+ };
1137
+ if (tx.provider === "codex" && isRecord(tx.seedUsage)) {
1138
+ nextUsage.providerUsageSnapshot = {
1139
+ input_tokens: Number(tx.seedUsage.input_tokens) || 0,
1140
+ cached_input_tokens: Number(tx.seedUsage.cached_input_tokens) || 0,
1141
+ output_tokens: Number(tx.seedUsage.output_tokens) || 0,
1142
+ };
1143
+ }
1144
+ state.usageBySession[newUsageKey] = nextUsage;
1145
+ state.lastCompactedAt = now.getTime();
1146
+ if (getActiveProvider(state) === tx.provider && getProjectKey(state) === tx.project) {
1147
+ state.isFirstMessage = false;
1148
+ }
1149
+ state.completedCompactions = {
1150
+ ...cloneRecord(state.completedCompactions),
1151
+ [tx.operationId]: {
1152
+ project: tx.project,
1153
+ provider: tx.provider,
1154
+ previousSessionId: tx.previousSessionId,
1155
+ newSessionId: tx.newSessionId,
1156
+ committedAt: now.toISOString(),
1157
+ },
1158
+ };
1159
+ state.completedCompactions = Object.fromEntries(Object.entries(state.completedCompactions).slice(-20));
1160
+ state.pendingCompaction = { ...pending, phase: "committed", committedAt: now.toISOString() };
1161
+ requireCompactionWrite(persistState({ strict: true }), "compaction committed state");
1162
+ durableCommitted = true;
1163
+ if (typeof options.faultInjector === "function") options.faultInjector("after-compaction-state-commit", { transaction: tx });
1164
+
1165
+ const committedMarker = state.pendingCompaction;
1166
+ state.pendingCompaction = null;
1167
+ try { requireCompactionWrite(persistState({ strict: true }), "compaction marker cleanup"); }
1168
+ catch (_) {
1169
+ // The committed marker is itself durable and restart recovery will only
1170
+ // clear it; never report a successful pointer/history commit as failed.
1171
+ state.pendingCompaction = committedMarker;
1172
+ return { committed: true, duplicate: false, recoveryPending: true, operationId: tx.operationId, sessionId: tx.newSessionId };
1173
+ }
1174
+ return { committed: true, duplicate: false, operationId: tx.operationId, sessionId: tx.newSessionId };
1175
+ } catch (error) {
1176
+ if (durableCommitted) {
1177
+ return { committed: true, duplicate: false, recoveryPending: true, operationId: tx.operationId, sessionId: tx.newSessionId };
1178
+ }
1179
+ try {
1180
+ state.pendingCompaction = pending;
1181
+ restorePendingCompaction(state, pending, { persistState, replaceHistory });
1182
+ } catch (rollbackError) {
1183
+ error.rollbackError = rollbackError;
1184
+ }
1185
+ throw error;
209
1186
  }
210
- return false;
211
1187
  }
212
1188
 
213
1189
  function migrateUserData(fromUserId, toUserId) {
@@ -299,22 +1275,47 @@ function autoLinkOwnerChannel(transport, channelId) {
299
1275
  }
300
1276
 
301
1277
  module.exports = {
1278
+ STATE_SCHEMA_VERSION,
1279
+ SESSIONS_SCHEMA_VERSION,
1280
+ MAX_USAGE_ENTRIES,
1281
+ MAX_SESSIONS_PER_PROJECT,
1282
+ MAX_SESSION_CALLBACKS,
1283
+ SESSION_CALLBACK_TTL_MS,
1284
+ PROVIDER_SETTING_KEYS,
302
1285
  userStates,
303
1286
  savedState,
304
1287
  freshSettings,
305
1288
  freshUsage,
1289
+ migrateStateDocument,
1290
+ getActiveProvider,
1291
+ getProjectKey,
1292
+ getProviderSession,
1293
+ setProviderSession,
1294
+ clearProviderSession,
1295
+ getProviderSettings,
1296
+ getActiveUsage,
1297
+ getUsageForSession,
1298
+ usageSessionKey,
306
1299
  createUserState,
307
1300
  getUserState,
308
1301
  currentState,
309
1302
  resetSessionUsage,
310
1303
  resetSettings,
1304
+ acquireRunLock,
311
1305
  saveState,
312
1306
  loadSessions,
1307
+ loadSessionsDocument,
1308
+ migrateSessionsDocument,
313
1309
  saveSessions,
314
1310
  recordSession,
1311
+ replaceProjectSessions,
315
1312
  getProjectSessions,
316
1313
  getLastProjectSession,
317
- userOwnsClaudeSession,
1314
+ userOwnsProviderSession,
1315
+ createSessionCallbackToken,
1316
+ consumeSessionCallbackToken,
1317
+ commitProviderCompaction,
1318
+ recoverPendingCompaction,
318
1319
  migrateUserData,
319
1320
  linkIdentity,
320
1321
  autoLinkOwnerChannel,