@inetafrica/open-claudia 2.15.0 → 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 (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -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 +66 -3
  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 +115 -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 +21 -4
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +117 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +121 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +170 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. package/web.js +130 -35
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,
@@ -18,50 +22,492 @@ const {
18
22
  } = require("./identity");
19
23
  const { currentChannelId, currentCanonicalUserId } = require("./context");
20
24
  const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
25
+ const {
26
+ defaultMigrationSources,
27
+ markMigrationComponentActivated,
28
+ markMigrationComponentPrepared,
29
+ requireMigrationSnapshotForComponent,
30
+ } = require("./migration-backup");
21
31
 
22
- function loadStateFile() {
23
- return readJsonWithFallback(STATE_FILE, {});
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;
24
128
  }
25
129
 
26
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
+ }
27
139
  return {
28
140
  ...existing,
29
141
  ...next,
30
- settings: { ...(existing.settings || {}), ...(next.settings || {}) },
31
- 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
+ },
32
173
  };
33
174
  }
34
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
+
35
346
  // Multi-user state. Legacy top-level shapes are migrated to
36
347
  // `{ users: { "<canonicalId>": {...} } }` on load.
348
+ const hadPersistedStateFile = fs.existsSync(STATE_FILE);
37
349
  const savedState = (() => {
38
350
  const raw = loadStateFile();
39
- const users = {};
40
- if (raw && raw.users && typeof raw.users === "object") {
41
- for (const [key, value] of Object.entries(raw.users)) {
42
- const userId = canonicalForStoredUserKey(key);
43
- users[userId] = mergeSavedState(users[userId] || {}, value || {});
44
- }
45
- return { users };
46
- }
47
- return { users: { [canonicalForTelegram(CHAT_ID || "")]: raw || {} } };
351
+ return migrateStateDocument(raw, {
352
+ defaultUserId: canonicalForTelegram(CHAT_ID || ""),
353
+ canonicalizeUserId: canonicalForStoredUserKey,
354
+ });
48
355
  })();
49
356
 
50
357
  const userStates = new Map();
51
358
 
52
- function freshSettings() {
53
- 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;
54
368
  }
55
369
 
56
- function freshUsage() {
57
- 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;
58
497
  }
59
498
 
60
499
  function createUserState(userId) {
61
500
  const saved = (savedState.users && savedState.users[userId]) || {};
62
- const settings = saved.settings || freshSettings();
63
- if (!settings.backend) settings.backend = "claude";
64
- 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 = {
65
511
  userId: String(userId),
66
512
  channelId: currentChannelId(),
67
513
  currentSession: saved.currentSession || null,
@@ -78,15 +524,16 @@ function createUserState(userId) {
78
524
  statusMessageChannel: null,
79
525
  streamBuffer: "",
80
526
  streamInterval: null,
81
- lastSessionId: saved.lastSessionId || null,
82
- cursorSessionId: saved.cursorSessionId || null,
83
- codexSessionId: saved.codexSessionId || null,
84
- codexUsageSnapshots: saved.codexUsageSnapshots || {},
527
+ activeSessions: normalizeActiveSessions(saved.activeSessions),
528
+ providerSettings: normalizeProviderSettings(saved.providerSettings),
529
+ usageBySession: boundUsageBySession(saved.usageBySession),
85
530
  messageQueue: [],
86
- isFirstMessage: !saved.lastSessionId,
531
+ isFirstMessage: true,
87
532
  lastInputWasVoice: false,
88
- sessionUsage: saved.sessionUsage || freshUsage(),
89
533
  settings,
534
+ legacyProviderState: cloneRecord(saved.legacyProviderState),
535
+ pendingCompaction: isRecord(saved.pendingCompaction) ? structuredClone(saved.pendingCompaction) : null,
536
+ completedCompactions: cloneRecord(saved.completedCompactions),
90
537
  onboardingStep: null,
91
538
  onboardingData: {},
92
539
  pendingVaultUnlock: false,
@@ -98,12 +545,23 @@ function createUserState(userId) {
98
545
  isCompacting: false,
99
546
  lastCompactedAt: saved.lastCompactedAt || 0,
100
547
  };
548
+ attachRuntimeAccessors(state);
549
+ state.isFirstMessage = !getProviderSession(state, getActiveProvider(state), getProjectKey(state));
550
+ return state;
101
551
  }
102
552
 
103
553
  function getUserState(userId) {
104
554
  const id = normalizeCanonicalUserId(userId);
105
555
  if (!userStates.has(id)) userStates.set(id, createUserState(id));
106
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
+ }
107
565
  state.channelId = currentChannelId() || state.channelId;
108
566
  return state;
109
567
  }
@@ -114,11 +572,12 @@ function currentState() {
114
572
 
115
573
  function resetSessionUsage(state = currentState()) {
116
574
  state.sessionUsage = freshUsage();
117
- state.codexUsageSnapshots = {};
118
575
  }
119
576
 
120
577
  function resetSettings(state = currentState()) {
121
- state.settings = freshSettings();
578
+ state.providerSettings = normalizeProviderSettings({});
579
+ state.settings = commonSettingsFrom(freshSettings(freshDefaultProvider()));
580
+ attachRuntimeAccessors(state);
122
581
  }
123
582
 
124
583
  // Synchronous single-flight run-lock. Returns true and arms the lock if no turn
@@ -126,101 +585,605 @@ function resetSettings(state = currentState()) {
126
585
  // must be NO await between calling this and the work it guards, so two turns for
127
586
  // one canonical user (owner's Telegram + Kazee sharing one state under unified
128
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.
129
589
  function acquireRunLock(state = currentState()) {
130
- if (state.runningProcess || state.preparingRun) return false;
590
+ if (state.runningProcess || state.preparingRun || state.isCompacting) return false;
131
591
  state.preparingRun = true;
132
592
  return true;
133
593
  }
134
594
 
135
- function saveState() {
136
- 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 || {}) } };
137
662
  for (const [id, s] of userStates) {
138
- data.users[id] = {
139
- currentSession: s.currentSession,
140
- lastSessionId: s.lastSessionId,
141
- cursorSessionId: s.cursorSessionId,
142
- codexSessionId: s.codexSessionId,
143
- codexUsageSnapshots: s.codexUsageSnapshots || {},
144
- settings: s.settings,
145
- sessionUsage: s.sessionUsage,
146
- lastCompactedAt: s.lastCompactedAt || 0,
147
- };
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;
148
677
  }
149
- savedState.users = data.users;
150
- try { atomicWriteFileSync(STATE_FILE, JSON.stringify(data), { backup: true }); }
151
- catch (e) { console.error("saveState: write failed:", e.message); }
678
+ catch (e) {
679
+ console.error("saveState: write failed:", e.message);
680
+ if (options.strict) throw e;
681
+ return false;
682
+ }
683
+ return true;
152
684
  }
153
685
 
154
686
  // ── Sessions ────────────────────────────────────────────────────────
155
687
 
156
- function loadSessions() {
157
- const raw = readJsonWithFallback(SESSIONS_FILE, null);
158
- if (!raw || typeof raw !== "object") return {};
159
- const looksLegacy = Object.values(raw).some((v) => Array.isArray(v));
160
- if (looksLegacy) return { [canonicalForTelegram(CHAT_ID || "")]: raw };
161
- const migrated = {};
162
- for (const [key, value] of Object.entries(raw)) {
163
- const userId = canonicalForStoredUserKey(key);
164
- migrated[userId] = { ...(migrated[userId] || {}) };
165
- for (const [project, list] of Object.entries(value || {})) {
166
- migrated[userId][project] = [
167
- ...(migrated[userId][project] || []),
168
- ...(Array.isArray(list) ? list : []),
169
- ].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";
170
735
  }
171
736
  }
172
- 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
+ };
173
752
  }
174
753
 
175
- function saveSessions(sessions) {
176
- try { atomicWriteFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), { backup: true }); }
177
- catch (e) { console.error("saveSessions: write failed:", e.message); }
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);
178
765
  }
179
766
 
180
- function recordSession(userId, projectName, sessionId, title) {
181
- const all = loadSessions();
182
- const id = normalizeCanonicalUserId(userId);
183
- if (!all[id]) all[id] = {};
184
- if (!all[id][projectName]) all[id][projectName] = [];
185
- const arr = all[id][projectName];
186
- const existing = arr.find((s) => s.id === sessionId);
187
- if (existing) {
188
- if (title) existing.title = title;
189
- existing.lastUsed = new Date().toISOString();
190
- } else {
191
- arr.push({
192
- id: sessionId,
193
- title: title || "Untitled",
194
- created: new Date().toISOString(),
195
- 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,
196
830
  });
831
+ markMigrationComponentPrepared(snapshot.snapshotDir, "sessions", {
832
+ configDir: CONFIG_DIR,
833
+ sources,
834
+ requireSourceMatch: !alreadyProviderAware,
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;
849
+ }
850
+ return true;
851
+ }
852
+
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");
197
859
  }
198
- all[id][projectName] = arr.slice(-20);
199
- saveSessions(all);
860
+ return { userId: user, project, provider, sessionId };
200
861
  }
201
862
 
202
- function getProjectSessions(userId, projectName) {
863
+ function recordSession(userId, projectName, providerId, sessionId, title, options = {}) {
864
+ const identity = requiredSessionIdentity(userId, projectName, providerId, sessionId);
203
865
  const all = loadSessions();
204
- 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;
205
893
  }
206
894
 
207
- function getLastProjectSession(userId, projectName) {
208
- const sessions = getProjectSessions(userId, projectName);
209
- 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;
210
908
  }
211
909
 
212
- // Cross-user leakage guard: only resume a Claude session id we have
213
- // recorded for this user. Stops migration glitches from handing one
214
- // person's conversation to another.
215
- function userOwnsClaudeSession(userId, sessionId) {
216
- 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);
217
917
  const all = loadSessions();
218
- const bucket = all[normalizeCanonicalUserId(userId)];
219
- if (!bucket) return false;
220
- for (const list of Object.values(bucket)) {
221
- 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;
222
1186
  }
223
- return false;
224
1187
  }
225
1188
 
226
1189
  function migrateUserData(fromUserId, toUserId) {
@@ -312,10 +1275,27 @@ function autoLinkOwnerChannel(transport, channelId) {
312
1275
  }
313
1276
 
314
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,
315
1285
  userStates,
316
1286
  savedState,
317
1287
  freshSettings,
318
1288
  freshUsage,
1289
+ migrateStateDocument,
1290
+ getActiveProvider,
1291
+ getProjectKey,
1292
+ getProviderSession,
1293
+ setProviderSession,
1294
+ clearProviderSession,
1295
+ getProviderSettings,
1296
+ getActiveUsage,
1297
+ getUsageForSession,
1298
+ usageSessionKey,
319
1299
  createUserState,
320
1300
  getUserState,
321
1301
  currentState,
@@ -324,11 +1304,18 @@ module.exports = {
324
1304
  acquireRunLock,
325
1305
  saveState,
326
1306
  loadSessions,
1307
+ loadSessionsDocument,
1308
+ migrateSessionsDocument,
327
1309
  saveSessions,
328
1310
  recordSession,
1311
+ replaceProjectSessions,
329
1312
  getProjectSessions,
330
1313
  getLastProjectSession,
331
- userOwnsClaudeSession,
1314
+ userOwnsProviderSession,
1315
+ createSessionCallbackToken,
1316
+ consumeSessionCallbackToken,
1317
+ commitProviderCompaction,
1318
+ recoverPendingCompaction,
332
1319
  migrateUserData,
333
1320
  linkIdentity,
334
1321
  autoLinkOwnerChannel,