@evomap/evolver 1.89.17 → 1.89.19

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 (86) hide show
  1. package/README.ja-JP.md +1 -1
  2. package/README.ko-KR.md +1 -1
  3. package/README.md +1 -1
  4. package/README.zh-CN.md +1 -1
  5. package/index.js +272 -18
  6. package/package.json +7 -2
  7. package/src/adapters/claudeCode.js +16 -18
  8. package/src/adapters/hookAdapter.js +92 -5
  9. package/src/adapters/scripts/evolver-session-start.js +98 -0
  10. package/src/atp/atpExecute.js +3 -1
  11. package/src/atp/hubClient.js +5 -3
  12. package/src/atp/serviceHelper.js +3 -2
  13. package/src/evolve/guards.js +1 -1
  14. package/src/evolve/pipeline/collect.js +1 -1
  15. package/src/evolve/pipeline/dispatch.js +1 -1
  16. package/src/evolve/pipeline/enrich.js +1 -1
  17. package/src/evolve/pipeline/hub.js +1 -1
  18. package/src/evolve/pipeline/select.js +1 -1
  19. package/src/evolve/pipeline/signals.js +1 -1
  20. package/src/evolve/utils.js +1 -1
  21. package/src/evolve.js +1 -1
  22. package/src/gep/a2aProtocol.js +1 -1
  23. package/src/gep/antiAbuseTelemetry.js +1 -1
  24. package/src/gep/autoDistillConv.js +1 -1
  25. package/src/gep/autoDistillLlm.js +1 -1
  26. package/src/gep/candidateEval.js +1 -1
  27. package/src/gep/candidates.js +1 -1
  28. package/src/gep/cliContracts.js +37 -1
  29. package/src/gep/contentHash.js +1 -1
  30. package/src/gep/conversationDistiller.js +1 -1
  31. package/src/gep/conversationSniffer.js +1 -1
  32. package/src/gep/crypto.js +1 -1
  33. package/src/gep/curriculum.js +1 -1
  34. package/src/gep/deviceId.js +1 -1
  35. package/src/gep/directoryClient.js +7 -3
  36. package/src/gep/envFingerprint.js +1 -1
  37. package/src/gep/epigenetics.js +1 -1
  38. package/src/gep/execBridge.js +1 -1
  39. package/src/gep/explore.js +1 -1
  40. package/src/gep/hash.js +1 -1
  41. package/src/gep/hubFetch.js +1 -1
  42. package/src/gep/hubReview.js +1 -1
  43. package/src/gep/hubSearch.js +1 -1
  44. package/src/gep/hubVerify.js +1 -1
  45. package/src/gep/learningSignals.js +1 -1
  46. package/src/gep/memoryGraph.js +1 -1
  47. package/src/gep/memoryGraphAdapter.js +1 -1
  48. package/src/gep/mutation.js +1 -1
  49. package/src/gep/narrativeMemory.js +1 -1
  50. package/src/gep/oauthLogin.js +3 -5
  51. package/src/gep/openPRRegistry.js +1 -1
  52. package/src/gep/personality.js +1 -1
  53. package/src/gep/policyCheck.js +1 -1
  54. package/src/gep/privacyClient.js +28 -10
  55. package/src/gep/prompt.js +1 -1
  56. package/src/gep/recallInject.js +1 -1
  57. package/src/gep/recallVerifier.js +1 -1
  58. package/src/gep/reflection.js +1 -1
  59. package/src/gep/savingsCore.js +1 -1
  60. package/src/gep/selector.js +1 -1
  61. package/src/gep/skill2gep.js +329 -43
  62. package/src/gep/skill2gepAudit.js +303 -0
  63. package/src/gep/skillDistiller.js +1 -1
  64. package/src/gep/skillPublisher.js +8 -3
  65. package/src/gep/solidify.js +1 -1
  66. package/src/gep/strategy.js +1 -1
  67. package/src/gep/taskReceiver.js +3 -2
  68. package/src/gep/tokenSavings.js +1 -1
  69. package/src/gep/trajectoryExport.js +1 -0
  70. package/src/gep/validator/index.js +7 -2
  71. package/src/gep/validator/reporter.js +7 -2
  72. package/src/gep/validator/stakeBootstrap.js +7 -2
  73. package/src/gep/workspaceKeychain.js +1 -1
  74. package/src/proxy/clientSettings.js +405 -0
  75. package/src/proxy/extensions/traceControl.js +1 -1
  76. package/src/proxy/index.js +13 -5
  77. package/src/proxy/inject.js +1 -1
  78. package/src/proxy/lifecycle/manager.js +82 -16
  79. package/src/proxy/mailbox/state.js +207 -0
  80. package/src/proxy/mailbox/store.js +164 -64
  81. package/src/proxy/router/messages_route.js +56 -4
  82. package/src/proxy/server/http.js +40 -6
  83. package/src/proxy/sync/inbound.js +31 -12
  84. package/src/proxy/sync/outbound.js +164 -24
  85. package/src/proxy/trace/extractor.js +1 -1
  86. package/src/proxy/trace/usage.js +1 -1
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const PRIVATE_DIR_MODE = 0o700;
7
+ const PRIVATE_FILE_MODE = 0o600;
8
+ const STATE_LOCK_STALE_MS = 10_000;
9
+ const STATE_LOCK_WAIT_MS = 10;
10
+
11
+ const MAILBOX_NODE_SECRET_STATE_KEYS = Object.freeze([
12
+ 'node_secret',
13
+ 'node_secret_version',
14
+ 'node_secret_source',
15
+ 'node_secret_env_suppressed',
16
+ ]);
17
+
18
+ const MAILBOX_NODE_SECRET_STATE_KEY_SET = new Set(MAILBOX_NODE_SECRET_STATE_KEYS);
19
+ const MAILBOX_NODE_SECRET_TUPLE_KEYS = Object.freeze([
20
+ 'node_secret',
21
+ 'node_secret_version',
22
+ 'node_secret_source',
23
+ ]);
24
+ const MAILBOX_NODE_SECRET_TUPLE_KEY_SET = new Set(MAILBOX_NODE_SECRET_TUPLE_KEYS);
25
+
26
+ function bestEffortChmod(filePath, mode) {
27
+ try { fs.chmodSync(filePath, mode); } catch { /* best effort; no-op on Windows */ }
28
+ }
29
+
30
+ function ensurePrivateDir(dir) {
31
+ if (!fs.existsSync(dir)) {
32
+ fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
33
+ }
34
+ bestEffortChmod(dir, PRIVATE_DIR_MODE);
35
+ }
36
+
37
+ function writePrivateFile(filePath, content) {
38
+ fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: PRIVATE_FILE_MODE });
39
+ bestEffortChmod(filePath, PRIVATE_FILE_MODE);
40
+ }
41
+
42
+ function sleepSync(ms) {
43
+ try {
44
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
45
+ } catch {
46
+ const end = Date.now() + ms;
47
+ while (Date.now() < end) {}
48
+ }
49
+ }
50
+
51
+ function acquireStateFileLock(stateFile) {
52
+ ensurePrivateDir(path.dirname(stateFile));
53
+ const lockDir = `${stateFile}.lock`;
54
+ const startedAt = Date.now();
55
+ while (true) {
56
+ try {
57
+ fs.mkdirSync(lockDir, { mode: PRIVATE_DIR_MODE });
58
+ bestEffortChmod(lockDir, PRIVATE_DIR_MODE);
59
+ return function releaseStateFileLock() {
60
+ try { fs.rmdirSync(lockDir); } catch {}
61
+ };
62
+ } catch (e) {
63
+ if (!e || e.code !== 'EEXIST') throw e;
64
+ try {
65
+ const ageMs = Date.now() - fs.statSync(lockDir).mtimeMs;
66
+ if (ageMs > STATE_LOCK_STALE_MS) {
67
+ fs.rmSync(lockDir, { recursive: true, force: true });
68
+ continue;
69
+ }
70
+ } catch (statErr) {
71
+ if (statErr && statErr.code === 'ENOENT') continue;
72
+ throw statErr;
73
+ }
74
+ if (Date.now() - startedAt > STATE_LOCK_STALE_MS) {
75
+ const err = new Error('timed out waiting for mailbox state lock');
76
+ err.code = 'MAILBOX_STATE_LOCK_TIMEOUT';
77
+ throw err;
78
+ }
79
+ sleepSync(STATE_LOCK_WAIT_MS);
80
+ }
81
+ }
82
+ }
83
+
84
+ function isPlainState(value) {
85
+ return value && typeof value === 'object' && !Array.isArray(value);
86
+ }
87
+
88
+ function isHubRotatedNodeSecretState(state) {
89
+ return isPlainState(state)
90
+ && state.node_secret_source === 'hub_rotate'
91
+ && typeof state.node_secret === 'string'
92
+ && Boolean(state.node_secret.trim());
93
+ }
94
+
95
+ function parseNodeSecretVersion(value) {
96
+ const n = Number(value);
97
+ return Number.isInteger(n) && n > 0 ? n : null;
98
+ }
99
+
100
+ function isFullNodeSecretTupleUpdate(updatedSet) {
101
+ return Boolean(updatedSet) && MAILBOX_NODE_SECRET_TUPLE_KEYS.every((key) => updatedSet.has(key));
102
+ }
103
+
104
+ function canApplyPartialNodeSecretTupleWrite(key, disk, next) {
105
+ if (key !== 'node_secret_version') return false;
106
+ if (!isHubRotatedNodeSecretState(disk)) return true;
107
+ if (!isHubRotatedNodeSecretState(next)) return false;
108
+ if (String(next.node_secret).trim() !== String(disk.node_secret).trim()) return false;
109
+ const nextVersion = parseNodeSecretVersion(next.node_secret_version);
110
+ const diskVersion = parseNodeSecretVersion(disk.node_secret_version);
111
+ return Boolean(nextVersion && (!diskVersion || nextVersion >= diskVersion));
112
+ }
113
+
114
+ /**
115
+ * @param {string} stateFile
116
+ * @returns {Record<string, unknown>|null}
117
+ */
118
+ function readMailboxStateFile(stateFile) {
119
+ try {
120
+ if (!fs.existsSync(stateFile)) return null;
121
+ const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
122
+ return isPlainState(parsed) ? parsed : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ function replaceStateFile(stateFile, state) {
129
+ const dir = path.dirname(stateFile);
130
+ ensurePrivateDir(dir);
131
+ const tmp = `${stateFile}.${process.pid}.tmp`;
132
+ writePrivateFile(tmp, JSON.stringify(state || {}, null, 2) + '\n');
133
+ // Windows: renameSync throws EPERM when the destination file already
134
+ // exists, unlike POSIX where rename(2) atomically replaces the target.
135
+ if (process.platform === 'win32') {
136
+ try { fs.unlinkSync(stateFile); } catch (e) {
137
+ if (e.code !== 'ENOENT') throw e;
138
+ }
139
+ }
140
+ fs.renameSync(tmp, stateFile);
141
+ bestEffortChmod(stateFile, PRIVATE_FILE_MODE);
142
+ }
143
+
144
+ /**
145
+ * Merge a partial state write with the latest on-disk state.
146
+ *
147
+ * Callers pass the keys they intentionally changed. Secret-bearing keys are
148
+ * only written when they are in `updatedKeys`, so an old long-running
149
+ * MailboxStore cannot overwrite a fresher Hub-rotated or divergence-cleared
150
+ * secret tuple that was written by another process between load and persist.
151
+ *
152
+ * @param {string} stateFile
153
+ * @param {Record<string, unknown>} nextState
154
+ * @param {Iterable<string>} updatedKeys
155
+ * @returns {Record<string, unknown>}
156
+ */
157
+ function writeMergedMailboxStateFile(stateFile, nextState, updatedKeys) {
158
+ const releaseLock = acquireStateFileLock(stateFile);
159
+ try {
160
+ const next = isPlainState(nextState) ? nextState : {};
161
+ const disk = readMailboxStateFile(stateFile);
162
+ const hasDiskState = isPlainState(disk);
163
+ const merged = hasDiskState ? { ...disk } : {};
164
+ const updatedSet = updatedKeys ? new Set(Array.from(updatedKeys)) : null;
165
+ const keys = updatedSet ? Array.from(updatedSet) : Object.keys(next);
166
+ const touchesNodeSecretTuple = keys.some((key) => MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key));
167
+ const preserveDiskNodeSecretTuple = touchesNodeSecretTuple
168
+ && hasDiskState
169
+ && isHubRotatedNodeSecretState(disk)
170
+ && !isFullNodeSecretTupleUpdate(updatedSet);
171
+
172
+ for (const key of keys) {
173
+ if (MAILBOX_NODE_SECRET_STATE_KEY_SET.has(key) && hasDiskState && (!updatedSet || !updatedSet.has(key))) {
174
+ continue;
175
+ }
176
+ if (
177
+ MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key) &&
178
+ preserveDiskNodeSecretTuple &&
179
+ !canApplyPartialNodeSecretTupleWrite(key, disk, next)
180
+ ) {
181
+ continue;
182
+ }
183
+ if (!Object.prototype.hasOwnProperty.call(next, key)) {
184
+ delete merged[key];
185
+ continue;
186
+ }
187
+ merged[key] = next[key];
188
+ }
189
+
190
+ replaceStateFile(stateFile, merged);
191
+ return merged;
192
+ } finally {
193
+ releaseLock();
194
+ }
195
+ }
196
+
197
+ module.exports = {
198
+ PRIVATE_DIR_MODE,
199
+ PRIVATE_FILE_MODE,
200
+ MAILBOX_NODE_SECRET_STATE_KEYS,
201
+ bestEffortChmod,
202
+ ensurePrivateDir,
203
+ writePrivateFile,
204
+ readMailboxStateFile,
205
+ isHubRotatedNodeSecretState,
206
+ writeMergedMailboxStateFile,
207
+ };
@@ -3,28 +3,20 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
+ const {
7
+ PRIVATE_FILE_MODE,
8
+ bestEffortChmod,
9
+ ensurePrivateDir,
10
+ writePrivateFile,
11
+ readMailboxStateFile,
12
+ writeMergedMailboxStateFile,
13
+ } = require('./state');
6
14
 
7
15
  const DEFAULT_CHANNEL = 'evomap-hub';
8
16
  const SCHEMA_VERSION = 1;
9
17
  const PROXY_PROTOCOL_VERSION = '0.1.0';
10
- const PRIVATE_DIR_MODE = 0o700;
11
- const PRIVATE_FILE_MODE = 0o600;
12
-
13
- function bestEffortChmod(filePath, mode) {
14
- try { fs.chmodSync(filePath, mode); } catch { /* best effort; no-op on Windows */ }
15
- }
16
-
17
- function ensurePrivateDir(dir) {
18
- if (!fs.existsSync(dir)) {
19
- fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
20
- }
21
- bestEffortChmod(dir, PRIVATE_DIR_MODE);
22
- }
23
-
24
- function writePrivateFile(filePath, content) {
25
- fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: PRIVATE_FILE_MODE });
26
- bestEffortChmod(filePath, PRIVATE_FILE_MODE);
27
- }
18
+ const DEFAULT_MAX_JSONL_LINE_BYTES = 16 * 1024 * 1024;
19
+ const JSONL_READ_CHUNK_BYTES = 64 * 1024;
28
20
 
29
21
  // Merge `fields` into `target` while stripping keys that can mutate the
30
22
  // prototype chain. Mailbox rows are persisted as JSONL and rebuilt on
@@ -107,22 +99,117 @@ function safeParse(payload) {
107
99
  // Windows NTFS provides the same atomicity guarantee for O_APPEND writes to
108
100
  // regular files that POSIX local filesystems do, so the removal above is
109
101
  // equally valid on Windows. No platform-specific code is needed here.
102
+ function jsonlLineForWrite(obj, opts = {}) {
103
+ const line = JSON.stringify(obj) + '\n';
104
+ const maxLineBytes = Math.max(1, Math.floor(Number(opts.maxLineBytes) || resolveMaxJsonlLineBytes()));
105
+ const lineBytes = Buffer.byteLength(line, 'utf8');
106
+ if (lineBytes > maxLineBytes) {
107
+ const err = new Error(`mailbox JSONL line is ${lineBytes} bytes; maximum is ${maxLineBytes} bytes`);
108
+ err.code = 'MAILBOX_JSONL_LINE_TOO_LARGE';
109
+ err.lineBytes = lineBytes;
110
+ err.maxLineBytes = maxLineBytes;
111
+ throw err;
112
+ }
113
+ return line;
114
+ }
115
+
110
116
  function appendLine(filePath, obj) {
111
- fs.appendFileSync(filePath, JSON.stringify(obj) + '\n', { encoding: 'utf8', mode: PRIVATE_FILE_MODE });
117
+ const line = jsonlLineForWrite(obj);
118
+ fs.appendFileSync(filePath, line, { encoding: 'utf8', mode: PRIVATE_FILE_MODE });
112
119
  bestEffortChmod(filePath, PRIVATE_FILE_MODE);
113
120
  }
114
121
 
115
- function readLines(filePath) {
116
- if (!fs.existsSync(filePath)) return [];
117
- const content = fs.readFileSync(filePath, 'utf8');
118
- const lines = content.split('\n');
119
- const results = [];
120
- for (const line of lines) {
121
- const trimmed = line.trim();
122
- if (!trimmed) continue;
123
- try { results.push(JSON.parse(trimmed)); } catch { /* skip corrupt lines */ }
122
+ function resolveMaxJsonlLineBytes(env = process.env) {
123
+ const raw = Number(env.EVOMAP_MAILBOX_MAX_LINE_BYTES);
124
+ if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
125
+ return DEFAULT_MAX_JSONL_LINE_BYTES;
126
+ }
127
+
128
+ function forEachJsonLine(filePath, onRow, opts = {}) {
129
+ const stats = { parsed: 0, corrupt: 0, overlong: 0 };
130
+ if (!fs.existsSync(filePath)) return stats;
131
+
132
+ const maxLineBytes = Math.max(1, Math.floor(Number(opts.maxLineBytes) || resolveMaxJsonlLineBytes()));
133
+ const chunk = Buffer.allocUnsafe(JSONL_READ_CHUNK_BYTES);
134
+ let fd = null;
135
+ let parts = [];
136
+ let lineBytes = 0;
137
+ let dropping = false;
138
+
139
+ const resetLine = () => {
140
+ parts = [];
141
+ lineBytes = 0;
142
+ dropping = false;
143
+ };
144
+
145
+ const appendSegment = (segment) => {
146
+ if (!segment || segment.length === 0 || dropping) return;
147
+ if (lineBytes + segment.length > maxLineBytes) {
148
+ parts = [];
149
+ lineBytes = 0;
150
+ dropping = true;
151
+ return;
152
+ }
153
+ parts.push(Buffer.from(segment));
154
+ lineBytes += segment.length;
155
+ };
156
+
157
+ const finishLine = (hasNewline = false) => {
158
+ const totalLineBytes = lineBytes + (hasNewline ? 1 : 0);
159
+ if (dropping) {
160
+ stats.overlong += 1;
161
+ resetLine();
162
+ return;
163
+ }
164
+ if (totalLineBytes > maxLineBytes) {
165
+ stats.overlong += 1;
166
+ resetLine();
167
+ return;
168
+ }
169
+ if (lineBytes === 0) {
170
+ resetLine();
171
+ return;
172
+ }
173
+ const trimmed = Buffer.concat(parts, lineBytes).toString('utf8').trim();
174
+ resetLine();
175
+ if (!trimmed) return;
176
+ try {
177
+ onRow(JSON.parse(trimmed));
178
+ stats.parsed += 1;
179
+ } catch {
180
+ stats.corrupt += 1;
181
+ }
182
+ };
183
+
184
+ try {
185
+ fd = fs.openSync(filePath, 'r');
186
+ while (true) {
187
+ const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null);
188
+ if (bytesRead <= 0) break;
189
+ let start = 0;
190
+ for (let i = 0; i < bytesRead; i++) {
191
+ if (chunk[i] !== 0x0a) continue;
192
+ appendSegment(chunk.subarray(start, i));
193
+ finishLine(true);
194
+ start = i + 1;
195
+ }
196
+ if (start < bytesRead) appendSegment(chunk.subarray(start, bytesRead));
197
+ }
198
+ finishLine();
199
+ } catch (err) {
200
+ stats.read_failed = 1;
201
+ const detail = err && err.message ? err.message : String(err);
202
+ const wrapped = new Error(`failed to read mailbox JSONL ${filePath}: ${detail}`);
203
+ wrapped.code = 'MAILBOX_JSONL_READ_FAILED';
204
+ wrapped.stats = stats;
205
+ wrapped.cause = err;
206
+ throw wrapped;
207
+ } finally {
208
+ if (fd !== null) {
209
+ try { fs.closeSync(fd); } catch { /* ignore close errors */ }
210
+ }
124
211
  }
125
- return results;
212
+ return stats;
126
213
  }
127
214
 
128
215
  // --- In-memory index that backs JSONL persistence ---
@@ -150,19 +237,21 @@ class MailboxStore {
150
237
  }
151
238
 
152
239
  _loadState() {
153
- try {
154
- if (fs.existsSync(this._stateFile)) {
155
- this._state = JSON.parse(fs.readFileSync(this._stateFile, 'utf8'));
156
- }
157
- } catch {
158
- this._state = {};
159
- }
240
+ this._state = readMailboxStateFile(this._stateFile) || {};
160
241
  const existingVersion = this._state._schema_version || 0;
242
+ const beforeMigrationState = { ...this._state };
161
243
  if (existingVersion < SCHEMA_VERSION) {
162
244
  this._runMigrations(existingVersion, SCHEMA_VERSION);
163
245
  }
164
246
  this._state._schema_version = SCHEMA_VERSION;
165
- this._persistState();
247
+ const updatedKeys = new Set(['_schema_version']);
248
+ for (const key of Object.keys(this._state)) {
249
+ if (this._state[key] !== beforeMigrationState[key]) updatedKeys.add(key);
250
+ }
251
+ for (const key of Object.keys(beforeMigrationState)) {
252
+ if (!Object.prototype.hasOwnProperty.call(this._state, key)) updatedKeys.add(key);
253
+ }
254
+ this._persistState(updatedKeys);
166
255
  }
167
256
 
168
257
  _runMigrations(fromVersion, toVersion) {
@@ -174,9 +263,7 @@ class MailboxStore {
174
263
  }
175
264
  }
176
265
 
177
- _persistState() {
178
- const dir = path.dirname(this._stateFile);
179
- ensurePrivateDir(dir);
266
+ _persistState(updatedKeys) {
180
267
  // Round-7 (§20.5): per-PID tmp path. Two evolver processes (daemon +
181
268
  // ad-hoc CLI / proxy + loop) writing to the same `${stateFile}.tmp`
182
269
  // would otherwise interleave: process B's writeFileSync truncates
@@ -186,20 +273,11 @@ class MailboxStore {
186
273
  // load-bearing trigger for the "401-loop -> reauth backoff -> dead
187
274
  // for 30 min..4 h" symptom this branch targets. Matches the
188
275
  // precedent set by _persistNodeSecret in src/gep/a2aProtocol.js.
189
- const tmp = `${this._stateFile}.${process.pid}.tmp`;
190
- writePrivateFile(tmp, JSON.stringify(this._state, null, 2) + '\n');
191
- // Windows: fs.renameSync throws EPERM when the destination file already
192
- // exists, unlike POSIX where rename(2) atomically replaces the target.
193
- // Remove the destination first so the rename succeeds on all platforms.
194
- // The window between unlink and rename is intentionally tiny; a crash in
195
- // that window leaves the tmp file behind (recovered on next _persistState).
196
- if (process.platform === 'win32') {
197
- try { fs.unlinkSync(this._stateFile); } catch (e) {
198
- if (e.code !== 'ENOENT') throw e;
199
- }
200
- }
201
- fs.renameSync(tmp, this._stateFile);
202
- bestEffortChmod(this._stateFile, PRIVATE_FILE_MODE);
276
+ //
277
+ // Use the shared merge helper so stale long-running stores cannot
278
+ // resurrect an older node_secret tuple after another path rotates or clears
279
+ // it directly on disk.
280
+ this._state = writeMergedMailboxStateFile(this._stateFile, this._state, updatedKeys);
203
281
  }
204
282
 
205
283
  _rebuildIndex() {
@@ -208,17 +286,20 @@ class MailboxStore {
208
286
  this._inbound = [];
209
287
 
210
288
  const TERMINAL = new Set(['synced', 'delivered', 'failed', 'rejected']);
211
- const rows = readLines(this._messagesFile);
212
- for (const rawRow of rows) {
289
+ const stats = forEachJsonLine(this._messagesFile, (rawRow) => {
213
290
  const row = sanitizeRow(rawRow);
291
+ if (!row || typeof row !== 'object') return;
214
292
  if (row._op === 'update') {
215
293
  const existing = this._messages.get(row.id);
216
294
  if (existing) safeAssign(existing, row.fields);
217
- continue;
295
+ return;
218
296
  }
297
+ if (!row.id) return;
219
298
  this._messages.set(row.id, row);
220
- }
299
+ });
300
+ this._lastRebuildStats = stats;
221
301
  for (const [id, msg] of this._messages) {
302
+ if (!msg || typeof msg !== 'object') continue;
222
303
  if (TERMINAL.has(msg.status)) continue;
223
304
  if (msg.direction === 'outbound') this._outbound.push(id);
224
305
  else if (msg.direction === 'inbound') this._inbound.push(id);
@@ -430,8 +511,9 @@ class MailboxStore {
430
511
  }
431
512
 
432
513
  setCursor(key, value) {
433
- this._state[`cursor:${key}`] = value;
434
- this._persistState();
514
+ const stateKey = `cursor:${key}`;
515
+ this._state[stateKey] = value;
516
+ this._persistState([stateKey]);
435
517
  }
436
518
 
437
519
  getState(key) {
@@ -441,7 +523,20 @@ class MailboxStore {
441
523
 
442
524
  setState(key, value) {
443
525
  this._state[key] = typeof value === 'string' ? value : JSON.stringify(value);
444
- this._persistState();
526
+ this._persistState([key]);
527
+ }
528
+
529
+ setNodeSecretState({ secret = '', version = '', source = '', envSuppressed = '' } = {}) {
530
+ this._state.node_secret = secret;
531
+ this._state.node_secret_version = version ? String(version) : '';
532
+ this._state.node_secret_source = source;
533
+ this._state.node_secret_env_suppressed = envSuppressed;
534
+ this._persistState([
535
+ 'node_secret',
536
+ 'node_secret_version',
537
+ 'node_secret_source',
538
+ 'node_secret_env_suppressed',
539
+ ]);
445
540
  }
446
541
 
447
542
  // --- Compaction (reduces JSONL file size by rewriting only current state) ---
@@ -457,11 +552,16 @@ class MailboxStore {
457
552
  }
458
553
  entries.sort((a, b) => a.created_at - b.created_at);
459
554
 
555
+ const lines = entries.map((msg) => jsonlLineForWrite(msg));
556
+
460
557
  const fd = fs.openSync(tmpFile, 'w', PRIVATE_FILE_MODE);
461
- for (const msg of entries) {
462
- fs.writeSync(fd, JSON.stringify(msg) + '\n');
558
+ try {
559
+ for (const line of lines) {
560
+ fs.writeSync(fd, line);
561
+ }
562
+ } finally {
563
+ fs.closeSync(fd);
463
564
  }
464
- fs.closeSync(fd);
465
565
  bestEffortChmod(tmpFile, PRIVATE_FILE_MODE);
466
566
  // Windows: renameSync throws EPERM when the destination already exists.
467
567
  // Remove it first so the swap succeeds on all platforms.
@@ -144,8 +144,8 @@ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStor
144
144
  return async ({ body, headers }) => {
145
145
  const inboundHeaders = headers || {};
146
146
  // x-api-key is satisfied by either the inbound header OR a proxy-side
147
- // ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env (token mediation, see
148
- // _proxyAnthropic). The proxy server itself has already auth-checked
147
+ // EVOMAP_ANTHROPIC_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env
148
+ // (token mediation, see _proxyAnthropic). The proxy server itself has already auth-checked
149
149
  // `Authorization: Bearer <proxy_token>` before reaching this handler.
150
150
  //
151
151
  // Bedrock upstream uses SigV4 with AWS_* env, so neither inbound
@@ -161,7 +161,8 @@ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStor
161
161
  if (upstreamMode !== 'bedrock') {
162
162
  const hasInboundKey = !!inboundHeaders['x-api-key'];
163
163
  const hasProxyEnvCreds = !!(
164
- process.env.ANTHROPIC_API_KEY
164
+ process.env.EVOMAP_ANTHROPIC_API_KEY
165
+ || process.env.ANTHROPIC_API_KEY
165
166
  || process.env.EVOMAP_ANTHROPIC_AUTH_TOKEN
166
167
  || (process.env.EVOMAP_PROXY_AUTO_INJECTED === '1' ? '' : process.env.ANTHROPIC_AUTH_TOKEN)
167
168
  );
@@ -289,6 +290,7 @@ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStor
289
290
  inboundHeaders,
290
291
  upstreamMode,
291
292
  });
293
+ if (upstream.traceRequestBody !== undefined) trace?.setRequestBody(upstream.traceRequestBody);
292
294
  } catch (err) {
293
295
  trace?.record({ status: 502, error: err, upstreamMode, model: chosenModel });
294
296
  throw err;
@@ -351,6 +353,7 @@ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStor
351
353
  // — but that loses the original 503 body, so cache it here too and
352
354
  // restore it on the throw path.
353
355
  let drainedFirst = '';
356
+ let firstAttemptResponseBody = {};
354
357
  if (upstream.text) {
355
358
  // Bound response-body drain to 10s to prevent hanging on large or
356
359
  // slow-streaming error responses. If the drain times out, log it
@@ -388,14 +391,47 @@ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStor
388
391
  if (drainTimer) clearTimeout(drainTimer);
389
392
  }
390
393
  }
394
+ if (drainedFirst.length > 0) {
395
+ try {
396
+ firstAttemptResponseBody = JSON.parse(drainedFirst);
397
+ } catch {
398
+ firstAttemptResponseBody = { error: drainedFirst };
399
+ }
400
+ }
401
+ trace?.recordAttempt({
402
+ attempt_index: 0,
403
+ status: upstream.status,
404
+ requestBody: upstream.traceRequestBody !== undefined ? upstream.traceRequestBody : outboundBody,
405
+ responseBody: firstAttemptResponseBody,
406
+ upstreamMode,
407
+ model: chosenModel,
408
+ headers: upstream.headers,
409
+ });
410
+ let retryBody = body;
391
411
  try {
392
- const retryBody = rewriteModel(body, originalModel);
412
+ retryBody = rewriteModel(body, originalModel);
393
413
  finalUpstream = await anthropicProxy('/v1/messages', retryBody, {
394
414
  inboundHeaders,
395
415
  upstreamMode,
396
416
  });
417
+ trace?.setRequestBody(finalUpstream.traceRequestBody !== undefined ? finalUpstream.traceRequestBody : retryBody);
418
+ trace?.recordAttempt({
419
+ attempt_index: 1,
420
+ requestBody: finalUpstream.traceRequestBody !== undefined ? finalUpstream.traceRequestBody : retryBody,
421
+ upstreamMode,
422
+ model: originalModel,
423
+ headers: finalUpstream.headers,
424
+ });
397
425
  finalModel = originalModel;
398
426
  } catch (err) {
427
+ trace?.recordAttempt({
428
+ attempt_index: 1,
429
+ status: 502,
430
+ requestBody: retryBody,
431
+ error: err,
432
+ upstreamMode,
433
+ model: originalModel,
434
+ });
399
435
  // Replay the drained first response so the caller still sees the
400
436
  // original 503 + body, not an empty stream.
401
437
  finalUpstream = {
@@ -424,6 +460,7 @@ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStor
424
460
  upstreamMode,
425
461
  model: finalModel,
426
462
  headers: forwardHeaders,
463
+ attempt_index: finalModel === originalModel && chosenModel !== originalModel ? 1 : undefined,
427
464
  });
428
465
  return {
429
466
  status: finalUpstream.status,
@@ -460,6 +497,21 @@ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled, traceStor
460
497
  }));
461
498
  }
462
499
  }
500
+ if (
501
+ enabled
502
+ && chosenModel
503
+ && chosenModel !== originalModel
504
+ && finalModel === originalModel
505
+ ) {
506
+ trace?.recordAttempt({
507
+ attempt_index: 1,
508
+ status: finalUpstream.status,
509
+ responseBody: respBody,
510
+ upstreamMode,
511
+ model: finalModel,
512
+ headers: finalUpstream.headers,
513
+ });
514
+ }
463
515
  trace?.record({
464
516
  status: finalUpstream.status,
465
517
  responseBody: respBody,