@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.
- package/.env.example +30 -4
- package/CHANGELOG.md +21 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +105 -7
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +127 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/core/web-sessions.js +78 -0
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +51 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-delivery-contract.js +88 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-run-lock.js +63 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/test-web-sessions.js +74 -0
- 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
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
56
|
-
return {
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
codexUsageSnapshots: saved.codexUsageSnapshots || {},
|
|
527
|
+
activeSessions: normalizeActiveSessions(saved.activeSessions),
|
|
528
|
+
providerSettings: normalizeProviderSettings(saved.providerSettings),
|
|
529
|
+
usageBySession: boundUsageBySession(saved.usageBySession),
|
|
84
530
|
messageQueue: [],
|
|
85
|
-
isFirstMessage:
|
|
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.
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
|
|
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
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
saveSessions(all);
|
|
850
|
+
return true;
|
|
187
851
|
}
|
|
188
852
|
|
|
189
|
-
function
|
|
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
|
-
|
|
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
|
|
195
|
-
const
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
1314
|
+
userOwnsProviderSession,
|
|
1315
|
+
createSessionCallbackToken,
|
|
1316
|
+
consumeSessionCallbackToken,
|
|
1317
|
+
commitProviderCompaction,
|
|
1318
|
+
recoverPendingCompaction,
|
|
318
1319
|
migrateUserData,
|
|
319
1320
|
linkIdentity,
|
|
320
1321
|
autoLinkOwnerChannel,
|