@ekkos/cli 0.2.18 → 0.3.3

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/capture/eviction-client.d.ts +139 -0
  3. package/dist/capture/eviction-client.js +454 -0
  4. package/dist/capture/index.d.ts +2 -0
  5. package/dist/capture/index.js +2 -0
  6. package/dist/capture/jsonl-rewriter.d.ts +96 -0
  7. package/dist/capture/jsonl-rewriter.js +1369 -0
  8. package/dist/capture/transcript-repair.d.ts +50 -0
  9. package/dist/capture/transcript-repair.js +308 -0
  10. package/dist/commands/doctor.js +23 -1
  11. package/dist/commands/run.d.ts +2 -0
  12. package/dist/commands/run.js +1229 -293
  13. package/dist/commands/usage.d.ts +7 -0
  14. package/dist/commands/usage.js +214 -0
  15. package/dist/cron/index.d.ts +7 -0
  16. package/dist/cron/index.js +13 -0
  17. package/dist/cron/promoter.d.ts +70 -0
  18. package/dist/cron/promoter.js +403 -0
  19. package/dist/index.js +24 -3
  20. package/dist/lib/usage-monitor.d.ts +47 -0
  21. package/dist/lib/usage-monitor.js +124 -0
  22. package/dist/lib/usage-parser.d.ts +72 -0
  23. package/dist/lib/usage-parser.js +238 -0
  24. package/dist/restore/RestoreOrchestrator.d.ts +4 -0
  25. package/dist/restore/RestoreOrchestrator.js +118 -30
  26. package/package.json +12 -12
  27. package/templates/cursor-hooks/after-agent-response.sh +0 -0
  28. package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
  29. package/templates/cursor-hooks/stop.sh +0 -0
  30. package/templates/ekkos-manifest.json +2 -2
  31. package/templates/hooks/assistant-response.sh +0 -0
  32. package/templates/hooks/session-start.sh +0 -0
  33. package/templates/plan-template.md +0 -0
  34. package/templates/spec-template.md +0 -0
  35. package/templates/agents/README.md +0 -182
  36. package/templates/agents/code-reviewer.md +0 -166
  37. package/templates/agents/debug-detective.md +0 -169
  38. package/templates/agents/ekkOS_Vercel.md +0 -99
  39. package/templates/agents/extension-manager.md +0 -229
  40. package/templates/agents/git-companion.md +0 -185
  41. package/templates/agents/github-test-agent.md +0 -321
  42. package/templates/agents/railway-manager.md +0 -215
@@ -0,0 +1,1369 @@
1
+ "use strict";
2
+ /**
3
+ * JSONL Sliding Window - Maximize Context for New Work
4
+ *
5
+ * Progressive eviction - always stay lean:
6
+ * - 60%+: trim junk (streaming chunks only)
7
+ * - 65%+: trim metadata (snapshots, system) + truncate tool_results
8
+ * - 70%+: trim tools (results, calls)
9
+ * - 80%+: emergency dump to 50%
10
+ *
11
+ * THINKING BLOCKS: Preserved (priority 6) - contain valuable reasoning
12
+ * Safety: Last 50 lines are NEVER evicted (recent work protection)
13
+ *
14
+ * PROXY MODE (EKKOS_PROXY_MODE=1):
15
+ * When API proxy is enabled, eviction happens at the API level before
16
+ * requests reach Anthropic. In this mode, JSONL rewriter only does
17
+ * junk cleanup (continuousClean) - no threshold-based eviction.
18
+ * This prevents duplicate eviction from two sources.
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.evictWithHandshake = evictWithHandshake;
55
+ exports.confirmLocalEviction = confirmLocalEviction;
56
+ exports.isHandshakeEvictionAvailable = isHandshakeEvictionAvailable;
57
+ exports.evictToTarget = evictToTarget;
58
+ exports.needsEviction = needsEviction;
59
+ exports.evictToTargetAsync = evictToTargetAsync;
60
+ exports.continuousClean = continuousClean;
61
+ exports.emergencyEvict = emergencyEvict;
62
+ exports.getEvictionStats = getEvictionStats;
63
+ exports.getEvictedContent = getEvictedContent;
64
+ const fs = __importStar(require("fs"));
65
+ const path = __importStar(require("path"));
66
+ const os = __importStar(require("os"));
67
+ const eviction_client_js_1 = require("./eviction-client.js");
68
+ // Thresholds - evict earlier to prevent context wall
69
+ const TRIM_JUNK = 60; // streaming/thinking - always safe
70
+ const TRIM_META = 65; // file-history-snapshots, system messages (was 70)
71
+ const TRIM_TOOLS = 70; // tool_results/tool_uses - now at 70% (was 80)
72
+ const EMERGENCY = 80; // emergency eviction at 80% (was 85)
73
+ const TARGET = 50; // target percentage after eviction
74
+ // Safety settings
75
+ const PROTECT_RECENT_LINES = 50; // Never evict last 50 lines (~10 turns, was 100)
76
+ const TRUNCATE_THRESHOLD = 3000; // Truncate tool_results > 3KB (was 5KB, prefer truncation)
77
+ // Stats log path
78
+ const STATS_LOG = path.join(os.homedir(), '.ekkos', 'eviction-stats.json');
79
+ // ══════════════════════════════════════════════════════════════════════════════
80
+ // ROBUST DEBUG LOGGING - Trace exactly what causes 400 errors
81
+ // ══════════════════════════════════════════════════════════════════════════════
82
+ const DEBUG_LOG = path.join(os.homedir(), '.ekkos', 'logs', 'eviction-debug.log');
83
+ function ensureLogDir() {
84
+ const dir = path.dirname(DEBUG_LOG);
85
+ if (!fs.existsSync(dir)) {
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ }
88
+ }
89
+ function debugLog(category, message, data) {
90
+ ensureLogDir();
91
+ const timestamp = new Date().toISOString();
92
+ const line = `[${timestamp}] [${category}] ${message}${data ? '\n ' + JSON.stringify(data, null, 2).replace(/\n/g, '\n ') : ''}`;
93
+ fs.appendFileSync(DEBUG_LOG, line + '\n');
94
+ }
95
+ function logToolPairs(toolUseIdToIndex, toolResultIndexToIds, inFlightLineIndices, category) {
96
+ const pairs = [];
97
+ for (const [toolUseId, useIndex] of toolUseIdToIndex) {
98
+ let resultIndex = null;
99
+ for (const [rIndex, ids] of toolResultIndexToIds) {
100
+ if (ids.includes(toolUseId)) {
101
+ resultIndex = rIndex;
102
+ break;
103
+ }
104
+ }
105
+ const status = inFlightLineIndices.has(useIndex)
106
+ ? 'IN_FLIGHT (protected)'
107
+ : resultIndex !== null
108
+ ? 'PAIRED'
109
+ : 'ORPHAN_USE';
110
+ pairs.push({ toolUseId, useIndex, resultIndex, status });
111
+ }
112
+ debugLog(category, `Tool pairs analysis (${pairs.length} total):`, pairs);
113
+ }
114
+ function logEvictionDecision(toRemove, lines, inFlightLineIndices) {
115
+ const decisions = lines.map(l => ({
116
+ index: l.index,
117
+ priority: l.priority,
118
+ protected: l.protected,
119
+ inFlight: inFlightLineIndices.has(l.index),
120
+ evicted: toRemove.has(l.index),
121
+ type: l.raw.includes('"type":"tool_use"') ? 'tool_use'
122
+ : l.raw.includes('"type":"tool_result"') ? 'tool_result'
123
+ : l.raw.includes('"type":"assistant"') ? 'assistant'
124
+ : l.raw.includes('"type":"human"') ? 'user'
125
+ : 'other',
126
+ toolIds: [...extractToolUseIds(l.raw), ...extractToolResultIds(l.raw)].slice(0, 3),
127
+ }));
128
+ debugLog('EVICTION_DECISIONS', `Eviction plan (${toRemove.size} to remove):`, decisions.filter(d => d.evicted || d.type.includes('tool')));
129
+ }
130
+ /**
131
+ * Validates file state after eviction - returns true if healthy (no orphaned tool_results)
132
+ */
133
+ function validateAndLogFinalState(filePath) {
134
+ try {
135
+ const content = fs.readFileSync(filePath, 'utf-8');
136
+ const lines = content.split('\n').filter(l => l.trim());
137
+ const toolUseIds = new Set();
138
+ const toolResultIds = new Set();
139
+ for (const line of lines) {
140
+ for (const id of extractToolUseIds(line))
141
+ toolUseIds.add(id);
142
+ for (const id of extractToolResultIds(line))
143
+ toolResultIds.add(id);
144
+ }
145
+ const orphanResults = [...toolResultIds].filter(id => !toolUseIds.has(id));
146
+ const orphanUses = [...toolUseIds].filter(id => !toolResultIds.has(id));
147
+ debugLog('FINAL_STATE', `After eviction:`, {
148
+ totalLines: lines.length,
149
+ toolUses: toolUseIds.size,
150
+ toolResults: toolResultIds.size,
151
+ orphanedResults: orphanResults,
152
+ orphanedUses: orphanUses,
153
+ healthy: orphanResults.length === 0,
154
+ });
155
+ if (orphanResults.length > 0) {
156
+ debugLog('ERROR', '❌ ORPHANED TOOL_RESULTS DETECTED - WILL CAUSE 400 ERROR', orphanResults);
157
+ }
158
+ return { healthy: orphanResults.length === 0, orphanedResults: orphanResults };
159
+ }
160
+ catch (e) {
161
+ debugLog('ERROR', 'Failed to analyze final state', { error: String(e) });
162
+ return { healthy: true, orphanedResults: [] }; // Assume healthy if we can't check
163
+ }
164
+ }
165
+ // Priority: 0 = evict first, 7 = keep longest
166
+ function getPriority(line) {
167
+ if (!line.includes('"type"'))
168
+ return 0; // streaming chunks
169
+ if (line.includes('file-history-snapshot'))
170
+ return 2;
171
+ if (line.includes('"type":"system"'))
172
+ return 3;
173
+ if (line.includes('"type":"tool_result"'))
174
+ return 4;
175
+ if (line.includes('"type":"tool_use"'))
176
+ return 5;
177
+ if (line.includes('"type":"thinking"'))
178
+ return 6; // KEEP thinking blocks - valuable reasoning
179
+ if (line.includes('"type":"assistant"'))
180
+ return 6;
181
+ if (line.includes('"type":"user"'))
182
+ return 7;
183
+ return 3;
184
+ }
185
+ function getThreshold(priority) {
186
+ if (priority <= 1)
187
+ return TRIM_JUNK;
188
+ if (priority <= 3)
189
+ return TRIM_META;
190
+ if (priority <= 5)
191
+ return TRIM_TOOLS;
192
+ return EMERGENCY;
193
+ }
194
+ /**
195
+ * Extract tool_use IDs from a line (assistant message with tool_use blocks)
196
+ * Uses proper JSON parsing to handle nested objects correctly.
197
+ */
198
+ function extractToolUseIds(line) {
199
+ const ids = [];
200
+ try {
201
+ const obj = JSON.parse(line);
202
+ const content = obj?.message?.content;
203
+ if (!Array.isArray(content))
204
+ return ids;
205
+ for (const block of content) {
206
+ if (block?.type === 'tool_use' && typeof block?.id === 'string') {
207
+ ids.push(block.id);
208
+ }
209
+ }
210
+ }
211
+ catch {
212
+ // Parse failed - fall back to regex for simple cases
213
+ // This regex works when "id" appears before nested objects
214
+ const regex = /"type"\s*:\s*"tool_use"\s*,\s*"id"\s*:\s*"(toolu_[^"]+)"/g;
215
+ let match;
216
+ while ((match = regex.exec(line)) !== null) {
217
+ ids.push(match[1]);
218
+ }
219
+ }
220
+ return ids;
221
+ }
222
+ /**
223
+ * Extract all tool_use_ids referenced by tool_result blocks in a line.
224
+ * Uses proper JSON parsing to handle nested objects correctly.
225
+ */
226
+ function extractToolResultIds(line) {
227
+ const ids = [];
228
+ try {
229
+ const obj = JSON.parse(line);
230
+ const content = obj?.message?.content;
231
+ if (!Array.isArray(content))
232
+ return ids;
233
+ for (const block of content) {
234
+ if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
235
+ ids.push(block.tool_use_id);
236
+ }
237
+ }
238
+ }
239
+ catch {
240
+ // Parse failed - fall back to regex
241
+ const regex = /"type"\s*:\s*"tool_result"[^}]*?"tool_use_id"\s*:\s*"(toolu_[^"]+)"/g;
242
+ let match;
243
+ while ((match = regex.exec(line)) !== null) {
244
+ ids.push(match[1]);
245
+ }
246
+ }
247
+ return ids;
248
+ }
249
+ /**
250
+ * Extract tool_use_id from a tool_result line (legacy single-result version)
251
+ * @deprecated Use extractToolResultIds for multi-result support
252
+ */
253
+ function extractToolResultId(line) {
254
+ const ids = extractToolResultIds(line);
255
+ return ids.length > 0 ? ids[0] : null;
256
+ }
257
+ /**
258
+ * Truncate a tool_result line - keep stub, remove bulk
259
+ */
260
+ function truncateToolResult(line) {
261
+ try {
262
+ const obj = JSON.parse(line);
263
+ if (!obj.message?.content)
264
+ return line;
265
+ const content = obj.message.content;
266
+ if (!Array.isArray(content))
267
+ return line;
268
+ let modified = false;
269
+ for (const block of content) {
270
+ if (block.type === 'tool_result' && typeof block.content === 'string') {
271
+ const originalLen = block.content.length;
272
+ if (originalLen > TRUNCATE_THRESHOLD) {
273
+ const lines = block.content.split('\n').length;
274
+ const preview = block.content.slice(0, 100).replace(/\n/g, ' ');
275
+ block.content = `[Truncated: ${lines} lines, ${(originalLen / 1024).toFixed(1)}KB] ${preview}...`;
276
+ modified = true;
277
+ }
278
+ }
279
+ }
280
+ return modified ? JSON.stringify(obj) : line;
281
+ }
282
+ catch {
283
+ return line;
284
+ }
285
+ }
286
+ // Evicted content store (for retrieval by ccDNA)
287
+ const EVICTED_STORE = path.join(os.homedir(), '.ekkos', 'evicted-context.jsonl');
288
+ const EKKOS_CAPTURE_ENDPOINT = 'https://mcp.ekkos.dev/api/v1/context/evict';
289
+ const EKKOS_API_URL = process.env.EKKOS_API_URL || 'https://mcp.ekkos.dev';
290
+ /**
291
+ * Evict messages using the Handshake Protocol (two-phase commit)
292
+ *
293
+ * CRITICAL: Returns success ONLY after R2 confirms backup.
294
+ * Caller must NOT delete locally until this returns success=true.
295
+ *
296
+ * @returns result with clientNonce for confirm phase, or error
297
+ */
298
+ async function evictWithHandshake(lines, indices, context) {
299
+ const authToken = process.env.EKKOS_AUTH_TOKEN || process.env.SUPABASE_AUTH_TOKEN;
300
+ const userId = context.userId || process.env.EKKOS_USER_ID;
301
+ const tenantId = context.tenantId || process.env.EKKOS_TENANT_ID || 'default';
302
+ if (!authToken) {
303
+ debugLog('HANDSHAKE_SKIP', 'No auth token available');
304
+ return {
305
+ success: false,
306
+ evictionId: 'none',
307
+ status: 'skipped',
308
+ error: 'No auth token',
309
+ };
310
+ }
311
+ if (!userId) {
312
+ debugLog('HANDSHAKE_SKIP', 'No user ID available');
313
+ return {
314
+ success: false,
315
+ evictionId: 'none',
316
+ status: 'skipped',
317
+ error: 'No user ID',
318
+ };
319
+ }
320
+ // Parse messages from JSONL lines
321
+ const messages = [];
322
+ for (const line of lines) {
323
+ try {
324
+ const parsed = JSON.parse(line);
325
+ if (parsed.message) {
326
+ messages.push({
327
+ role: parsed.message.role || 'user',
328
+ content: parsed.message.content,
329
+ });
330
+ }
331
+ }
332
+ catch {
333
+ // Skip unparseable lines
334
+ }
335
+ }
336
+ if (messages.length === 0) {
337
+ debugLog('HANDSHAKE_SKIP', 'No valid messages to evict');
338
+ return {
339
+ success: false,
340
+ evictionId: 'none',
341
+ status: 'skipped',
342
+ error: 'No valid messages',
343
+ };
344
+ }
345
+ // Estimate tokens
346
+ const totalChars = lines.reduce((sum, l) => sum + l.length, 0);
347
+ const estimatedTokens = Math.ceil(totalChars / 4);
348
+ const options = {
349
+ apiUrl: EKKOS_API_URL,
350
+ authToken,
351
+ sessionId: context.sessionId,
352
+ sessionName: context.sessionName,
353
+ userId,
354
+ tenantId,
355
+ projectPath: context.projectPath,
356
+ };
357
+ debugLog('HANDSHAKE_START', `Starting handshake eviction for ${messages.length} messages`, {
358
+ sessionName: context.sessionName,
359
+ estimatedTokens,
360
+ indices: indices.slice(0, 5), // Log first 5 indices
361
+ });
362
+ const result = await (0, eviction_client_js_1.handshakeEviction)(messages, indices, estimatedTokens, 'threshold', options);
363
+ if (result.success) {
364
+ debugLog('HANDSHAKE_SUCCESS', `Prepared eviction ${result.evictionId}`, {
365
+ r2Key: result.r2Key,
366
+ bytesWritten: result.bytesWritten,
367
+ status: result.status,
368
+ });
369
+ }
370
+ else {
371
+ debugLog('HANDSHAKE_FAIL', `Eviction failed: ${result.error}`, {
372
+ evictionId: result.evictionId,
373
+ status: result.status,
374
+ });
375
+ }
376
+ return result;
377
+ }
378
+ /**
379
+ * Complete the handshake by confirming local deletion
380
+ * Call this AFTER successfully deleting from local JSONL
381
+ */
382
+ async function confirmLocalEviction(evictionId, clientNonce, deletedCount) {
383
+ const authToken = process.env.EKKOS_AUTH_TOKEN || process.env.SUPABASE_AUTH_TOKEN;
384
+ if (!authToken)
385
+ return false;
386
+ const result = await (0, eviction_client_js_1.confirmEviction)(EKKOS_API_URL, authToken, evictionId, clientNonce, deletedCount);
387
+ if (result.success) {
388
+ debugLog('HANDSHAKE_CONFIRM', `Confirmed eviction ${evictionId} (${deletedCount} lines)`);
389
+ }
390
+ else {
391
+ // Non-critical - data is already safe in R2
392
+ debugLog('HANDSHAKE_CONFIRM_FAIL', `Confirm failed (non-critical): ${result.error}`);
393
+ }
394
+ return result.success;
395
+ }
396
+ /**
397
+ * Check if handshake eviction is available (proxy reachable)
398
+ */
399
+ async function isHandshakeEvictionAvailable() {
400
+ const authToken = process.env.EKKOS_AUTH_TOKEN || process.env.SUPABASE_AUTH_TOKEN;
401
+ if (!authToken)
402
+ return false;
403
+ // In proxy mode, handshake is handled by proxy - CLI should use fire-and-forget
404
+ if (process.env.EKKOS_PROXY_MODE === '1') {
405
+ return false;
406
+ }
407
+ return (0, eviction_client_js_1.checkEvictionHealth)(EKKOS_API_URL, authToken);
408
+ }
409
+ /**
410
+ * Send evicted content to ekkOS cloud for later retrieval (async, non-blocking)
411
+ */
412
+ async function captureEvictedToCloud(lines, sessionId) {
413
+ try {
414
+ // Get auth token from environment
415
+ const authToken = process.env.EKKOS_AUTH_TOKEN || process.env.SUPABASE_AUTH_TOKEN;
416
+ if (!authToken) {
417
+ debugLog('CLOUD_CAPTURE_SKIP', 'No auth token available');
418
+ return;
419
+ }
420
+ // Parse messages from JSONL lines
421
+ const messages = [];
422
+ for (const line of lines) {
423
+ try {
424
+ const parsed = JSON.parse(line);
425
+ if (parsed.message) {
426
+ messages.push({
427
+ role: parsed.message.role || 'unknown',
428
+ content: parsed.message.content,
429
+ });
430
+ }
431
+ }
432
+ catch {
433
+ // Skip unparseable lines
434
+ }
435
+ }
436
+ if (messages.length === 0)
437
+ return;
438
+ // Estimate tokens (rough: 1 token ≈ 4 chars)
439
+ const totalChars = lines.reduce((sum, l) => sum + l.length, 0);
440
+ const estimatedTokens = Math.ceil(totalChars / 4);
441
+ const payload = {
442
+ session_id: sessionId || 'unknown',
443
+ chunk_index: Date.now(), // Use timestamp as unique chunk index
444
+ messages,
445
+ token_count: estimatedTokens,
446
+ eviction_reason: 'sliding_window',
447
+ };
448
+ // Non-blocking fetch - don't await, let it happen in background
449
+ // 10s timeout to prevent lingering connections from burning resources
450
+ fetch(EKKOS_CAPTURE_ENDPOINT, {
451
+ method: 'POST',
452
+ headers: {
453
+ 'Content-Type': 'application/json',
454
+ 'Authorization': `Bearer ${authToken}`,
455
+ },
456
+ body: JSON.stringify(payload),
457
+ signal: AbortSignal.timeout(10000),
458
+ }).then(res => {
459
+ if (res.ok) {
460
+ debugLog('CLOUD_CAPTURE_OK', `Sent ${messages.length} msgs to cloud`);
461
+ }
462
+ else {
463
+ debugLog('CLOUD_CAPTURE_FAIL', `HTTP ${res.status}`);
464
+ }
465
+ }).catch(err => {
466
+ debugLog('CLOUD_CAPTURE_ERROR', err.message);
467
+ });
468
+ }
469
+ catch (err) {
470
+ debugLog('CLOUD_CAPTURE_ERROR', err.message);
471
+ }
472
+ }
473
+ /**
474
+ * Save evicted content for later retrieval
475
+ * Now supports handshake eviction when available
476
+ *
477
+ * @param lines - JSONL lines being evicted
478
+ * @param sessionId - Session ID (for legacy capture)
479
+ * @param sessionName - Session name (for handshake eviction)
480
+ * @param indices - Original indices of evicted lines
481
+ */
482
+ function saveEvictedContent(lines, sessionId, sessionName, indices) {
483
+ if (lines.length === 0)
484
+ return;
485
+ try {
486
+ // Extract ALL tool IDs from evicted content for debugging
487
+ const evictedToolUseIds = [];
488
+ const evictedToolResultIds = [];
489
+ for (const line of lines) {
490
+ for (const id of extractToolUseIds(line))
491
+ evictedToolUseIds.push(id);
492
+ for (const id of extractToolResultIds(line))
493
+ evictedToolResultIds.push(id);
494
+ }
495
+ const entry = {
496
+ timestamp: new Date().toISOString(),
497
+ lineCount: lines.length,
498
+ evictedToolUseIds,
499
+ evictedToolResultIds,
500
+ orphanedUses: evictedToolUseIds.filter(id => !evictedToolResultIds.includes(id)),
501
+ orphanedResults: evictedToolResultIds.filter(id => !evictedToolUseIds.includes(id)),
502
+ content: lines.slice(0, 20), // Save first 20 lines as sample
503
+ };
504
+ // Also log to debug file for immediate visibility
505
+ debugLog('EVICTED_CONTENT', `Saved ${lines.length} evicted lines`, {
506
+ toolUseCount: evictedToolUseIds.length,
507
+ toolResultCount: evictedToolResultIds.length,
508
+ orphanedUses: entry.orphanedUses,
509
+ orphanedResults: entry.orphanedResults,
510
+ });
511
+ fs.appendFileSync(EVICTED_STORE, JSON.stringify(entry) + '\n');
512
+ // Keep file under 1MB
513
+ const stats = fs.statSync(EVICTED_STORE);
514
+ if (stats.size > 1024 * 1024) {
515
+ const content = fs.readFileSync(EVICTED_STORE, 'utf-8');
516
+ const entries = content.split('\n').filter(l => l.trim());
517
+ fs.writeFileSync(EVICTED_STORE, entries.slice(-100).join('\n') + '\n');
518
+ }
519
+ // Send to cloud for cross-session retrieval (non-blocking)
520
+ captureEvictedToCloud(lines, sessionId);
521
+ }
522
+ catch {
523
+ // Silent fail
524
+ }
525
+ }
526
+ /**
527
+ * Log eviction stats to file
528
+ */
529
+ function logStats(stats) {
530
+ try {
531
+ let log = [];
532
+ if (fs.existsSync(STATS_LOG)) {
533
+ try {
534
+ log = JSON.parse(fs.readFileSync(STATS_LOG, 'utf-8'));
535
+ }
536
+ catch {
537
+ log = [];
538
+ }
539
+ }
540
+ log.push(stats);
541
+ // Keep last 100 entries
542
+ if (log.length > 100)
543
+ log = log.slice(-100);
544
+ fs.writeFileSync(STATS_LOG, JSON.stringify(log, null, 2));
545
+ }
546
+ catch {
547
+ // Silent fail for stats
548
+ }
549
+ }
550
+ function evictToTarget(filePath, currentPercent, sessionId, sessionName) {
551
+ debugLog('EVICTION_START', `evictToTarget called at ${currentPercent}%`, { filePath, currentPercent });
552
+ if (currentPercent < TRIM_JUNK) {
553
+ debugLog('EVICTION_SKIP', `Below threshold (${TRIM_JUNK}%), no eviction needed`);
554
+ return { success: true, evicted: 0, truncated: 0, newPercent: currentPercent };
555
+ }
556
+ if (!fs.existsSync(filePath)) {
557
+ debugLog('EVICTION_ERROR', 'File not found', { filePath });
558
+ return { success: false, evicted: 0, truncated: 0, newPercent: currentPercent };
559
+ }
560
+ const content = fs.readFileSync(filePath, 'utf-8');
561
+ // CRITICAL: Handle line endings carefully to avoid corruption
562
+ // - Files ending with \n: last element after split is empty, drop it
563
+ // - Files NOT ending with \n: last line might be incomplete OR might be valid
564
+ // → Include it only if it parses as valid JSON
565
+ const endsWithNewline = content.endsWith('\n');
566
+ const allLines = content.split('\n');
567
+ // For files ending with \n, drop the empty last element
568
+ // For files without trailing \n, keep last element (will be validated below)
569
+ const candidateLines = endsWithNewline
570
+ ? allLines.slice(0, -1) // Drop empty trailing element
571
+ : allLines; // Keep all, validate below
572
+ const rawLines = candidateLines
573
+ .filter(l => l.trim())
574
+ .filter(l => {
575
+ // Validate each line is valid JSON - this catches:
576
+ // 1. Incomplete lines from mid-write
577
+ // 2. Corrupted lines
578
+ // 3. Non-JSON content
579
+ try {
580
+ JSON.parse(l);
581
+ return true;
582
+ }
583
+ catch {
584
+ debugLog('INVALID_JSON_LINE', 'Skipping invalid JSON line', { preview: l.slice(0, 100) });
585
+ return false;
586
+ }
587
+ });
588
+ const totalBytes = Buffer.byteLength(content, 'utf-8');
589
+ debugLog('EVICTION_INIT', `Loaded file`, {
590
+ lines: rawLines.length,
591
+ bytes: totalBytes,
592
+ currentPercent,
593
+ });
594
+ const targetPercent = currentPercent >= EMERGENCY ? TARGET : currentPercent - 15;
595
+ const targetBytes = totalBytes * (targetPercent / currentPercent);
596
+ // First pass: truncate large tool_results (at 70%+)
597
+ let truncatedCount = 0;
598
+ const processedLines = rawLines.map(line => {
599
+ if (currentPercent >= TRIM_META && line.includes('"type":"tool_result"') && line.length > TRUNCATE_THRESHOLD) {
600
+ const truncated = truncateToolResult(line);
601
+ if (truncated !== line) {
602
+ truncatedCount++;
603
+ return truncated;
604
+ }
605
+ }
606
+ return line;
607
+ });
608
+ const totalLines = processedLines.length;
609
+ const protectedStart = Math.max(0, totalLines - PROTECT_RECENT_LINES);
610
+ const lines = processedLines.map((raw, index) => ({
611
+ raw,
612
+ index,
613
+ bytes: Buffer.byteLength(raw, 'utf-8') + 1,
614
+ priority: getPriority(raw),
615
+ protected: index >= protectedStart, // Protect recent turns
616
+ }));
617
+ // Build tool_use/tool_result pairing maps
618
+ // CRITICAL: Must keep pairs together to avoid API errors
619
+ const toolUseIdToIndex = new Map(); // tool_use_id -> line index
620
+ const toolResultIndexToIds = new Map(); // line index -> tool_use_ids it references
621
+ for (const line of lines) {
622
+ const toolUseIds = extractToolUseIds(line.raw);
623
+ for (const id of toolUseIds) {
624
+ toolUseIdToIndex.set(id, line.index);
625
+ }
626
+ const resultIds = extractToolResultIds(line.raw);
627
+ if (resultIds.length > 0) {
628
+ toolResultIndexToIds.set(line.index, resultIds);
629
+ }
630
+ }
631
+ // CRITICAL FIX: Identify "in-flight" tool_uses (those without results yet)
632
+ // These must NOT be evicted or we'll get orphaned tool_results when they complete
633
+ const completedToolUseIds = new Set();
634
+ for (const toolUseIds of toolResultIndexToIds.values()) {
635
+ for (const id of toolUseIds) {
636
+ completedToolUseIds.add(id);
637
+ }
638
+ }
639
+ // Find lines containing in-flight tool_uses (tool_use without result)
640
+ const inFlightLineIndices = new Set();
641
+ for (const [toolUseId, lineIndex] of toolUseIdToIndex) {
642
+ if (!completedToolUseIds.has(toolUseId)) {
643
+ inFlightLineIndices.add(lineIndex);
644
+ }
645
+ }
646
+ // Log the tool pairing state BEFORE eviction
647
+ debugLog('TOOL_PAIRS_BEFORE', `Tool pairing analysis before eviction`, {
648
+ totalToolUses: toolUseIdToIndex.size,
649
+ totalToolResults: toolResultIndexToIds.size,
650
+ completedPairs: completedToolUseIds.size,
651
+ inFlightCount: inFlightLineIndices.size,
652
+ inFlightLines: [...inFlightLineIndices],
653
+ });
654
+ logToolPairs(toolUseIdToIndex, toolResultIndexToIds, inFlightLineIndices, 'PAIRS_BEFORE');
655
+ // Recalculate bytes after truncation
656
+ let currentBytes = lines.reduce((sum, l) => sum + l.bytes, 0);
657
+ // Sort: lowest priority first, then oldest (but never protected or in-flight)
658
+ const evictionOrder = lines
659
+ .filter(l => !l.protected && !inFlightLineIndices.has(l.index))
660
+ .sort((a, b) => {
661
+ if (a.priority !== b.priority)
662
+ return a.priority - b.priority;
663
+ return a.index - b.index;
664
+ });
665
+ const toRemove = new Set();
666
+ const byPriority = {};
667
+ for (const line of evictionOrder) {
668
+ if (currentBytes <= targetBytes)
669
+ break;
670
+ const threshold = getThreshold(line.priority);
671
+ if (currentPercent >= threshold) {
672
+ toRemove.add(line.index);
673
+ currentBytes -= line.bytes;
674
+ byPriority[line.priority] = (byPriority[line.priority] || 0) + 1;
675
+ }
676
+ }
677
+ // CRITICAL: Ensure tool_use/tool_result pairs are evicted together
678
+ // If we evict a tool_use, we must also evict its tool_result (even if protected)
679
+ // If we keep a tool_result, we must also keep its tool_use
680
+ const pairAdjustments = [];
681
+ for (const [resultIndex, toolUseIds] of toolResultIndexToIds) {
682
+ for (const toolUseId of toolUseIds) {
683
+ const useIndex = toolUseIdToIndex.get(toolUseId);
684
+ if (useIndex === undefined)
685
+ continue;
686
+ const useEvicted = toRemove.has(useIndex);
687
+ const resultEvicted = toRemove.has(resultIndex);
688
+ if (useEvicted && !resultEvicted) {
689
+ // tool_use is evicted but tool_result isn't - evict the result too
690
+ toRemove.add(resultIndex);
691
+ const resultLine = lines[resultIndex];
692
+ if (resultLine) {
693
+ currentBytes -= resultLine.bytes;
694
+ byPriority[resultLine.priority] = (byPriority[resultLine.priority] || 0) + 1;
695
+ }
696
+ pairAdjustments.push({ action: 'EVICT_RESULT_WITH_USE', toolUseId, useIndex, resultIndex });
697
+ }
698
+ else if (!useEvicted && resultEvicted) {
699
+ // tool_result is evicted but tool_use isn't - also evict the use
700
+ toRemove.add(useIndex);
701
+ const useLine = lines[useIndex];
702
+ if (useLine) {
703
+ currentBytes -= useLine.bytes;
704
+ byPriority[useLine.priority] = (byPriority[useLine.priority] || 0) + 1;
705
+ }
706
+ pairAdjustments.push({ action: 'EVICT_USE_WITH_RESULT', toolUseId, useIndex, resultIndex });
707
+ }
708
+ }
709
+ }
710
+ if (pairAdjustments.length > 0) {
711
+ debugLog('PAIR_ADJUSTMENTS', `Adjusted ${pairAdjustments.length} pairs for consistency`, pairAdjustments);
712
+ }
713
+ // Log all eviction decisions
714
+ logEvictionDecision(toRemove, lines, inFlightLineIndices);
715
+ if (toRemove.size === 0 && truncatedCount === 0) {
716
+ debugLog('EVICTION_NOOP', 'No lines to evict or truncate');
717
+ return { success: true, evicted: 0, truncated: 0, newPercent: currentPercent };
718
+ }
719
+ // Save evicted content for ccDNA retrieval
720
+ const evictedLines = lines.filter(l => toRemove.has(l.index)).map(l => l.raw);
721
+ const evictedIndices = lines.filter(l => toRemove.has(l.index)).map(l => l.index);
722
+ // ═══════════════════════════════════════════════════════════════════════════
723
+ // HANDSHAKE EVICTION PROTOCOL (Two-Phase Commit)
724
+ // ═══════════════════════════════════════════════════════════════════════════
725
+ // CRITICAL: Do NOT delete locally until R2 confirms backup!
726
+ //
727
+ // Phase 1: Try handshake eviction (if available)
728
+ // Phase 2: Only delete locally after R2 ACK
729
+ // Fallback: Fire-and-forget if handshake unavailable
730
+ //
731
+ const useHandshake = sessionName && !process.env.EKKOS_PROXY_MODE;
732
+ let handshakeResult = null;
733
+ if (useHandshake) {
734
+ debugLog('HANDSHAKE_ATTEMPT', `Attempting handshake eviction for ${evictedLines.length} lines`);
735
+ // Sync wrapper for async handshake (evictToTarget is sync)
736
+ // We use a Promise that resolves immediately to check if we should proceed
737
+ const handshakePromise = evictWithHandshake(evictedLines, evictedIndices, {
738
+ sessionId: sessionId || 'unknown',
739
+ sessionName: sessionName,
740
+ projectPath: process.cwd(),
741
+ });
742
+ // For sync context, we'll use fire-and-forget but track the result
743
+ // The handshake will complete in background, but we proceed with local eviction
744
+ // This is a compromise - ideally evictToTarget should be async
745
+ handshakePromise.then(result => {
746
+ handshakeResult = result;
747
+ if (result.success) {
748
+ debugLog('HANDSHAKE_PREPARED', `Eviction ${result.evictionId} prepared in R2`, {
749
+ r2Key: result.r2Key,
750
+ bytesWritten: result.bytesWritten,
751
+ });
752
+ // Confirm after local deletion completes
753
+ if (result.clientNonce) {
754
+ confirmLocalEviction(result.evictionId, result.clientNonce, evictedLines.length);
755
+ }
756
+ }
757
+ else {
758
+ debugLog('HANDSHAKE_FAILED', `Handshake failed, data may be at risk: ${result.error}`);
759
+ }
760
+ }).catch(err => {
761
+ debugLog('HANDSHAKE_ERROR', `Handshake error: ${err}`);
762
+ });
763
+ }
764
+ // Legacy fire-and-forget (always runs as backup)
765
+ saveEvictedContent(evictedLines, sessionId, sessionName, evictedIndices);
766
+ debugLog('EVICTION_WRITE', `Writing evicted file`, {
767
+ evicting: toRemove.size,
768
+ keeping: lines.length - toRemove.size,
769
+ truncated: truncatedCount,
770
+ });
771
+ const kept = lines.filter(l => !toRemove.has(l.index)).map(l => l.raw);
772
+ // RACE CONDITION FIX: Check if file was modified during eviction
773
+ // If new lines were appended, we must preserve them
774
+ const tempPath = `${filePath}.tmp`;
775
+ // Handle empty kept array gracefully - don't produce leading newline
776
+ const keptContent = kept.length > 0 ? kept.join('\n') + '\n' : '';
777
+ // Get original file LENGTH for comparison (NOT byte size - slice uses char indices!)
778
+ const originalLength = content.length;
779
+ // Check current file before writing
780
+ let currentFileContent;
781
+ try {
782
+ currentFileContent = fs.readFileSync(filePath, 'utf-8');
783
+ }
784
+ catch {
785
+ currentFileContent = content;
786
+ }
787
+ const currentLength = currentFileContent.length;
788
+ if (currentLength > originalLength) {
789
+ // File grew during eviction! Append new content to our kept lines
790
+ // CRITICAL: Don't slice mid-line - find the boundary properly
791
+ const newContent = currentFileContent.slice(originalLength);
792
+ debugLog('RACE_CONDITION', `File grew during eviction by ${currentLength - originalLength} chars, preserving new content`);
793
+ // Parse new lines - only keep COMPLETE, VALID JSON lines
794
+ const newLines = newContent.split('\n')
795
+ .filter(l => l.trim())
796
+ .filter(l => {
797
+ try {
798
+ JSON.parse(l);
799
+ return true;
800
+ }
801
+ catch {
802
+ debugLog('RACE_INVALID_LINE', 'Skipping invalid JSON in new content', { preview: l.slice(0, 100) });
803
+ return false;
804
+ }
805
+ });
806
+ const newToolResultIds = [];
807
+ for (const line of newLines) {
808
+ for (const id of extractToolResultIds(line)) {
809
+ newToolResultIds.push(id);
810
+ }
811
+ }
812
+ // Check if any new tool_results reference tool_uses we just evicted
813
+ const evictedToolUseIds = new Set();
814
+ for (const idx of toRemove) {
815
+ const line = lines[idx];
816
+ if (line) {
817
+ for (const id of extractToolUseIds(line.raw)) {
818
+ evictedToolUseIds.add(id);
819
+ }
820
+ }
821
+ }
822
+ const orphanedNewResults = newToolResultIds.filter(id => evictedToolUseIds.has(id));
823
+ if (orphanedNewResults.length > 0) {
824
+ debugLog('RACE_ABORT', `New tool_results reference evicted tool_uses - ABORTING eviction to prevent orphans`, {
825
+ orphanedNewResults,
826
+ evictedToolUseIds: [...evictedToolUseIds],
827
+ });
828
+ // Abort eviction to prevent 400 errors
829
+ return { success: false, evicted: 0, truncated: 0, newPercent: currentPercent };
830
+ }
831
+ // Safe to proceed - append validated new lines (not raw slice!)
832
+ const validatedNewContent = newLines.length > 0 ? newLines.join('\n') + '\n' : '';
833
+ fs.writeFileSync(tempPath, keptContent + validatedNewContent);
834
+ }
835
+ else {
836
+ fs.writeFileSync(tempPath, keptContent);
837
+ }
838
+ // Save backup before rename (in case we need to rollback)
839
+ const backupPath = `${filePath}.backup`;
840
+ try {
841
+ // Read current file state for backup
842
+ const backupContent = fs.readFileSync(filePath, 'utf-8');
843
+ fs.writeFileSync(backupPath, backupContent);
844
+ }
845
+ catch {
846
+ // If we can't backup, proceed anyway
847
+ }
848
+ fs.renameSync(tempPath, filePath);
849
+ // Validate final state - rollback if unhealthy
850
+ const { healthy, orphanedResults } = validateAndLogFinalState(filePath);
851
+ if (!healthy) {
852
+ debugLog('ROLLBACK', `Rolling back eviction due to ${orphanedResults.length} orphaned tool_results`, {
853
+ orphanedResults,
854
+ });
855
+ // Attempt to restore from backup
856
+ if (fs.existsSync(backupPath)) {
857
+ try {
858
+ const backupContent = fs.readFileSync(backupPath, 'utf-8');
859
+ fs.writeFileSync(filePath, backupContent);
860
+ debugLog('ROLLBACK', 'Successfully restored from backup');
861
+ fs.unlinkSync(backupPath);
862
+ return { success: false, evicted: 0, truncated: 0, newPercent: currentPercent };
863
+ }
864
+ catch (e) {
865
+ debugLog('ROLLBACK_ERROR', 'Failed to restore from backup', { error: String(e) });
866
+ }
867
+ }
868
+ }
869
+ // Clean up backup on success
870
+ try {
871
+ if (fs.existsSync(backupPath))
872
+ fs.unlinkSync(backupPath);
873
+ }
874
+ catch {
875
+ // Silent fail
876
+ }
877
+ const newPercent = Math.round((currentBytes / totalBytes) * currentPercent);
878
+ // Log comprehensive summary
879
+ debugLog('EVICTION_SUMMARY', '═══════════════════════════════════════════════════════════', {
880
+ result: 'SUCCESS',
881
+ before: {
882
+ percent: currentPercent,
883
+ lines: totalLines,
884
+ bytes: totalBytes,
885
+ },
886
+ after: {
887
+ percent: newPercent,
888
+ lines: totalLines - toRemove.size,
889
+ bytes: currentBytes,
890
+ },
891
+ actions: {
892
+ evicted: toRemove.size,
893
+ truncated: truncatedCount,
894
+ byPriority: Object.entries(byPriority).map(([p, c]) => {
895
+ const names = ['streaming', 'thinking', 'snapshot', 'system', 'tool_result', 'tool_use', 'assistant', 'user'];
896
+ return `${names[parseInt(p)] || 'unknown'}:${c}`;
897
+ }).join(', '),
898
+ },
899
+ toolPairing: {
900
+ healthy: true,
901
+ toolUsesRemaining: toolUseIdToIndex.size - [...toRemove].filter(i => [...toolUseIdToIndex.values()].includes(i)).length,
902
+ toolResultsRemaining: toolResultIndexToIds.size - [...toRemove].filter(i => toolResultIndexToIds.has(i)).length,
903
+ },
904
+ });
905
+ // Log stats
906
+ logStats({
907
+ timestamp: new Date().toISOString(),
908
+ beforePercent: currentPercent,
909
+ afterPercent: newPercent,
910
+ evicted: toRemove.size,
911
+ truncated: truncatedCount,
912
+ byPriority,
913
+ });
914
+ return { success: true, evicted: toRemove.size, truncated: truncatedCount, newPercent };
915
+ }
916
+ function needsEviction(percent) {
917
+ // If proxy mode is ON, proxy handles eviction at API level
918
+ // JSONL rewriter only does junk cleanup (priority 0-1) via continuousClean()
919
+ if (process.env.EKKOS_PROXY_MODE === '1') {
920
+ return false; // Proxy handles eviction
921
+ }
922
+ return percent >= TRIM_JUNK;
923
+ }
924
+ // ═══════════════════════════════════════════════════════════════════════════════
925
+ // ASYNC EVICTION WITH PROPER TWO-PHASE COMMIT
926
+ // ═══════════════════════════════════════════════════════════════════════════════
927
+ //
928
+ // This is the CORRECT implementation of the Handshake Eviction Protocol:
929
+ // 1. Classify messages for eviction
930
+ // 2. Call ekkOS_EvictPrepare and WAIT for R2 confirmation
931
+ // 3. ONLY delete locally after R2 ACK
932
+ // 4. Confirm with ekkOS_EvictConfirm
933
+ //
934
+ // NEVER delete locally before R2 confirms!
935
+ /**
936
+ * Async eviction with proper two-phase commit handshake.
937
+ * Use this instead of evictToTarget when async context is available.
938
+ *
939
+ * @returns Promise<EvictionResult>
940
+ */
941
+ async function evictToTargetAsync(filePath, currentPercent, sessionId, sessionName) {
942
+ debugLog('ASYNC_EVICTION_START', `evictToTargetAsync called at ${currentPercent}%`, { filePath, currentPercent });
943
+ if (currentPercent < TRIM_JUNK) {
944
+ debugLog('ASYNC_EVICTION_SKIP', `Below threshold (${TRIM_JUNK}%), no eviction needed`);
945
+ return { success: true, evicted: 0, truncated: 0, newPercent: currentPercent, handshakeUsed: false };
946
+ }
947
+ if (!fs.existsSync(filePath)) {
948
+ debugLog('ASYNC_EVICTION_ERROR', 'File not found', { filePath });
949
+ return { success: false, evicted: 0, truncated: 0, newPercent: currentPercent, error: 'File not found' };
950
+ }
951
+ const content = fs.readFileSync(filePath, 'utf-8');
952
+ const endsWithNewline = content.endsWith('\n');
953
+ const allLines = content.split('\n');
954
+ const candidateLines = endsWithNewline ? allLines.slice(0, -1) : allLines;
955
+ const rawLines = candidateLines
956
+ .filter(l => l.trim())
957
+ .filter(l => {
958
+ try {
959
+ JSON.parse(l);
960
+ return true;
961
+ }
962
+ catch {
963
+ return false;
964
+ }
965
+ });
966
+ const totalBytes = Buffer.byteLength(content, 'utf-8');
967
+ const targetPercent = currentPercent >= EMERGENCY ? TARGET : currentPercent - 15;
968
+ // First pass: truncate large tool_results
969
+ let truncatedCount = 0;
970
+ const processedLines = rawLines.map(line => {
971
+ if (currentPercent >= TRIM_META && line.includes('"type":"tool_result"') && line.length > TRUNCATE_THRESHOLD) {
972
+ const truncated = truncateToolResult(line);
973
+ if (truncated !== line) {
974
+ truncatedCount++;
975
+ return truncated;
976
+ }
977
+ }
978
+ return line;
979
+ });
980
+ const totalLines = processedLines.length;
981
+ const protectedStart = Math.max(0, totalLines - PROTECT_RECENT_LINES);
982
+ const lines = processedLines.map((raw, index) => ({
983
+ raw,
984
+ index,
985
+ bytes: Buffer.byteLength(raw, 'utf-8') + 1,
986
+ priority: getPriority(raw),
987
+ protected: index >= protectedStart,
988
+ }));
989
+ // Build tool_use/tool_result pairing maps
990
+ const toolUseIdToIndex = new Map();
991
+ const toolResultIndexToIds = new Map();
992
+ for (const line of lines) {
993
+ const useIds = extractToolUseIds(line.raw);
994
+ for (const id of useIds) {
995
+ toolUseIdToIndex.set(id, line.index);
996
+ }
997
+ const resultIds = extractToolResultIds(line.raw);
998
+ if (resultIds.length > 0) {
999
+ toolResultIndexToIds.set(line.index, resultIds);
1000
+ }
1001
+ }
1002
+ // Check for in-flight tools
1003
+ const inFlightLineIndices = new Set();
1004
+ for (const [toolUseId, useIndex] of toolUseIdToIndex) {
1005
+ let hasResult = false;
1006
+ for (const [, resultIds] of toolResultIndexToIds) {
1007
+ if (resultIds.includes(toolUseId)) {
1008
+ hasResult = true;
1009
+ break;
1010
+ }
1011
+ }
1012
+ if (!hasResult) {
1013
+ inFlightLineIndices.add(useIndex);
1014
+ }
1015
+ }
1016
+ // Classify lines for eviction
1017
+ const toRemove = new Set();
1018
+ let currentBytes = totalBytes;
1019
+ const targetBytes = totalBytes * (targetPercent / currentPercent);
1020
+ const sortedByPriority = [...lines].sort((a, b) => {
1021
+ if (a.priority !== b.priority)
1022
+ return a.priority - b.priority;
1023
+ return a.index - b.index;
1024
+ });
1025
+ const byPriority = {};
1026
+ for (const line of sortedByPriority) {
1027
+ if (currentBytes <= targetBytes)
1028
+ break;
1029
+ if (line.protected)
1030
+ continue;
1031
+ if (inFlightLineIndices.has(line.index))
1032
+ continue;
1033
+ // Check tool result dependencies
1034
+ const resultIds = toolResultIndexToIds.get(line.index);
1035
+ if (resultIds) {
1036
+ let hasOrphanedUse = false;
1037
+ for (const id of resultIds) {
1038
+ const useIndex = toolUseIdToIndex.get(id);
1039
+ if (useIndex !== undefined && !toRemove.has(useIndex)) {
1040
+ hasOrphanedUse = true;
1041
+ break;
1042
+ }
1043
+ }
1044
+ if (hasOrphanedUse)
1045
+ continue;
1046
+ }
1047
+ toRemove.add(line.index);
1048
+ currentBytes -= line.bytes;
1049
+ byPriority[line.priority] = (byPriority[line.priority] || 0) + 1;
1050
+ }
1051
+ // Enforce pair consistency
1052
+ for (const [toolUseId, useIndex] of toolUseIdToIndex) {
1053
+ if (toRemove.has(useIndex))
1054
+ continue;
1055
+ for (const [resultIndex, resultIds] of toolResultIndexToIds) {
1056
+ if (resultIds.includes(toolUseId) && toRemove.has(resultIndex)) {
1057
+ const useLine = lines[useIndex];
1058
+ if (!useLine.protected && !inFlightLineIndices.has(useIndex)) {
1059
+ toRemove.add(useIndex);
1060
+ currentBytes -= useLine.bytes;
1061
+ byPriority[useLine.priority] = (byPriority[useLine.priority] || 0) + 1;
1062
+ }
1063
+ }
1064
+ }
1065
+ }
1066
+ if (toRemove.size === 0 && truncatedCount === 0) {
1067
+ debugLog('ASYNC_EVICTION_NOOP', 'No lines to evict or truncate');
1068
+ return { success: true, evicted: 0, truncated: 0, newPercent: currentPercent, handshakeUsed: false };
1069
+ }
1070
+ const evictedLines = lines.filter(l => toRemove.has(l.index)).map(l => l.raw);
1071
+ const evictedIndices = lines.filter(l => toRemove.has(l.index)).map(l => l.index);
1072
+ // ═══════════════════════════════════════════════════════════════════════════
1073
+ // TWO-PHASE COMMIT: HANDSHAKE EVICTION
1074
+ // ═══════════════════════════════════════════════════════════════════════════
1075
+ // CRITICAL: Await R2 confirmation before local deletion!
1076
+ const useHandshake = sessionName && !process.env.EKKOS_PROXY_MODE;
1077
+ let handshakeSucceeded = false;
1078
+ let evictionId;
1079
+ let clientNonce;
1080
+ if (useHandshake) {
1081
+ debugLog('ASYNC_HANDSHAKE_START', `Awaiting handshake for ${evictedLines.length} lines`);
1082
+ try {
1083
+ const handshakeResult = await evictWithHandshake(evictedLines, evictedIndices, {
1084
+ sessionId: sessionId || 'unknown',
1085
+ sessionName: sessionName,
1086
+ projectPath: process.cwd(),
1087
+ });
1088
+ if (handshakeResult.success) {
1089
+ handshakeSucceeded = true;
1090
+ evictionId = handshakeResult.evictionId;
1091
+ clientNonce = handshakeResult.clientNonce;
1092
+ debugLog('ASYNC_HANDSHAKE_SUCCESS', `R2 confirmed eviction ${evictionId}`, {
1093
+ r2Key: handshakeResult.r2Key,
1094
+ bytesWritten: handshakeResult.bytesWritten,
1095
+ status: handshakeResult.status,
1096
+ });
1097
+ }
1098
+ else {
1099
+ debugLog('ASYNC_HANDSHAKE_FAILED', `Handshake failed: ${handshakeResult.error}`, {
1100
+ status: handshakeResult.status,
1101
+ });
1102
+ // Fall through to fire-and-forget fallback
1103
+ }
1104
+ }
1105
+ catch (err) {
1106
+ debugLog('ASYNC_HANDSHAKE_ERROR', `Handshake error: ${err}`);
1107
+ // Fall through to fire-and-forget fallback
1108
+ }
1109
+ }
1110
+ // If handshake failed/unavailable, fall back to local-only eviction
1111
+ // This prevents session death when R2/proxy is down — local eviction is
1112
+ // better than hitting Anthropic's context limit (HTTP 400)
1113
+ if (!handshakeSucceeded) {
1114
+ debugLog('ASYNC_HANDSHAKE_FALLBACK', 'Handshake failed/unavailable - falling back to local-only eviction (queued for R2 sync)');
1115
+ // Save evicted content locally as best-effort backup
1116
+ saveEvictedContent(evictedLines, sessionId, sessionName, evictedIndices);
1117
+ // Queue for automatic R2 sync when connection is re-established
1118
+ const authToken = process.env.EKKOS_AUTH_TOKEN || process.env.SUPABASE_AUTH_TOKEN;
1119
+ const userId = process.env.EKKOS_USER_ID;
1120
+ const tenantId = process.env.EKKOS_TENANT_ID || 'default';
1121
+ if (authToken && userId && sessionName) {
1122
+ // Parse JSONL lines into messages for the retry queue
1123
+ const queueMessages = [];
1124
+ for (const line of evictedLines) {
1125
+ try {
1126
+ const parsed = JSON.parse(line);
1127
+ if (parsed.message) {
1128
+ queueMessages.push({ role: parsed.message.role || 'user', content: parsed.message.content });
1129
+ }
1130
+ }
1131
+ catch { /* skip */ }
1132
+ }
1133
+ if (queueMessages.length > 0) {
1134
+ (0, eviction_client_js_1.queueEvictionForRetry)(queueMessages, evictedIndices, Math.ceil(evictedLines.reduce((s, l) => s + l.length, 0) / 4), 'threshold', {
1135
+ apiUrl: EKKOS_API_URL,
1136
+ authToken,
1137
+ sessionId: sessionId || 'unknown',
1138
+ sessionName,
1139
+ userId,
1140
+ tenantId,
1141
+ projectPath: process.cwd(),
1142
+ });
1143
+ }
1144
+ }
1145
+ }
1146
+ // ═══════════════════════════════════════════════════════════════════════════
1147
+ // PHASE 2: NOW SAFE TO DELETE LOCALLY
1148
+ // ═══════════════════════════════════════════════════════════════════════════
1149
+ debugLog('ASYNC_EVICTION_WRITE', `Writing evicted file`, {
1150
+ evicting: toRemove.size,
1151
+ keeping: lines.length - toRemove.size,
1152
+ truncated: truncatedCount,
1153
+ handshakeUsed: handshakeSucceeded,
1154
+ });
1155
+ const kept = lines.filter(l => !toRemove.has(l.index)).map(l => l.raw);
1156
+ const keptContent = kept.length > 0 ? kept.join('\n') + '\n' : '';
1157
+ // Race condition check
1158
+ const originalLength = content.length;
1159
+ let currentFileContent;
1160
+ try {
1161
+ currentFileContent = fs.readFileSync(filePath, 'utf-8');
1162
+ }
1163
+ catch {
1164
+ currentFileContent = content;
1165
+ }
1166
+ const currentLength = currentFileContent.length;
1167
+ if (currentLength > originalLength) {
1168
+ const newContent = currentFileContent.slice(originalLength);
1169
+ debugLog('ASYNC_RACE_CONDITION', `File grew during eviction`, { delta: currentLength - originalLength });
1170
+ const newLines = newContent.split('\n')
1171
+ .filter(l => l.trim())
1172
+ .filter(l => {
1173
+ try {
1174
+ JSON.parse(l);
1175
+ return true;
1176
+ }
1177
+ catch {
1178
+ return false;
1179
+ }
1180
+ });
1181
+ // Check for orphaned tool_results
1182
+ const newToolResultIds = [];
1183
+ for (const line of newLines) {
1184
+ for (const id of extractToolResultIds(line)) {
1185
+ newToolResultIds.push(id);
1186
+ }
1187
+ }
1188
+ const evictedToolUseIds = new Set();
1189
+ for (const idx of toRemove) {
1190
+ const line = lines[idx];
1191
+ if (line) {
1192
+ for (const id of extractToolUseIds(line.raw)) {
1193
+ evictedToolUseIds.add(id);
1194
+ }
1195
+ }
1196
+ }
1197
+ const orphanedNewResults = newToolResultIds.filter(id => evictedToolUseIds.has(id));
1198
+ if (orphanedNewResults.length > 0) {
1199
+ debugLog('ASYNC_RACE_ABORT', `Aborting - orphaned tool_results detected`, { orphanedNewResults });
1200
+ return { success: false, evicted: 0, truncated: 0, newPercent: currentPercent, error: 'Race condition - orphaned tool_results' };
1201
+ }
1202
+ // Safe to append new lines
1203
+ const finalContent = keptContent + newLines.join('\n') + (newLines.length > 0 ? '\n' : '');
1204
+ fs.writeFileSync(filePath, finalContent);
1205
+ }
1206
+ else {
1207
+ fs.writeFileSync(filePath, keptContent);
1208
+ }
1209
+ // ═══════════════════════════════════════════════════════════════════════════
1210
+ // PHASE 3: CONFIRM EVICTION (if handshake was used)
1211
+ // ═══════════════════════════════════════════════════════════════════════════
1212
+ if (handshakeSucceeded && evictionId && clientNonce) {
1213
+ // Confirm in background (non-blocking) - data is already safe
1214
+ confirmLocalEviction(evictionId, clientNonce, evictedLines.length).catch(err => {
1215
+ debugLog('ASYNC_CONFIRM_WARN', `Confirm failed (non-critical): ${err}`);
1216
+ });
1217
+ // R2 is back! Drain the retry queue in background (auto-sync queued evictions)
1218
+ const authToken = process.env.EKKOS_AUTH_TOKEN || process.env.SUPABASE_AUTH_TOKEN;
1219
+ if (authToken) {
1220
+ (0, eviction_client_js_1.drainRetryQueue)(EKKOS_API_URL, authToken).then(result => {
1221
+ if (result.drained > 0) {
1222
+ debugLog('RETRY_QUEUE_DRAIN', `Auto-synced ${result.drained} queued evictions to R2`, result);
1223
+ }
1224
+ }).catch(err => {
1225
+ debugLog('RETRY_QUEUE_ERROR', `Drain failed (non-critical): ${err}`);
1226
+ });
1227
+ }
1228
+ }
1229
+ const newPercent = Math.round((currentBytes / totalBytes) * currentPercent);
1230
+ debugLog('ASYNC_EVICTION_COMPLETE', 'Two-phase commit complete', {
1231
+ evicted: toRemove.size,
1232
+ truncated: truncatedCount,
1233
+ newPercent,
1234
+ handshakeUsed: handshakeSucceeded,
1235
+ evictionId,
1236
+ });
1237
+ return {
1238
+ success: true,
1239
+ evicted: toRemove.size,
1240
+ truncated: truncatedCount,
1241
+ newPercent,
1242
+ handshakeUsed: handshakeSucceeded,
1243
+ evictionId,
1244
+ };
1245
+ }
1246
+ /**
1247
+ * Continuous clean - run every turn, remove junk regardless of threshold
1248
+ * Removes: thinking blocks, streaming chunks, old file-history-snapshots
1249
+ */
1250
+ function continuousClean(filePath) {
1251
+ if (!fs.existsSync(filePath))
1252
+ return { cleaned: 0 };
1253
+ const content = fs.readFileSync(filePath, 'utf-8');
1254
+ // CRITICAL: Handle line endings carefully to avoid corruption
1255
+ // - Files ending with \n: last element after split is empty, drop it
1256
+ // - Files NOT ending with \n: last line might be incomplete OR might be valid
1257
+ // → Keep all and let JSON validation filter incomplete lines
1258
+ const endsWithNewline = content.endsWith('\n');
1259
+ const allLines = content.split('\n');
1260
+ const candidateLines = endsWithNewline
1261
+ ? allLines.slice(0, -1) // Drop empty trailing element
1262
+ : allLines; // Keep all, validate below
1263
+ const lines = candidateLines
1264
+ .filter(l => l.trim())
1265
+ .filter(l => {
1266
+ try {
1267
+ JSON.parse(l);
1268
+ return true;
1269
+ }
1270
+ catch {
1271
+ return false; // Skip invalid JSON silently in continuous clean
1272
+ }
1273
+ });
1274
+ const totalLines = lines.length;
1275
+ const protectedStart = Math.max(0, totalLines - PROTECT_RECENT_LINES);
1276
+ // Remove junk (priority 0-1) from non-protected lines
1277
+ const kept = [];
1278
+ let cleaned = 0;
1279
+ lines.forEach((line, index) => {
1280
+ const isProtected = index >= protectedStart;
1281
+ const priority = getPriority(line);
1282
+ // Always remove streaming chunks (priority 0-1) unless protected
1283
+ // Note: Thinking blocks are now priority 6 (kept)
1284
+ if (!isProtected && priority <= 1) {
1285
+ cleaned++;
1286
+ return; // skip this line
1287
+ }
1288
+ kept.push(line);
1289
+ });
1290
+ if (cleaned === 0)
1291
+ return { cleaned: 0 };
1292
+ // RACE CONDITION FIX: Preserve any new lines added during cleaning
1293
+ const tempPath = `${filePath}.tmp`;
1294
+ // Handle empty kept array gracefully - don't produce leading newline
1295
+ const keptContent = kept.length > 0 ? kept.join('\n') + '\n' : '';
1296
+ const originalLength = content.length; // Use char length, NOT byte size!
1297
+ try {
1298
+ const currentContent = fs.readFileSync(filePath, 'utf-8');
1299
+ const currentLength = currentContent.length;
1300
+ if (currentLength > originalLength) {
1301
+ // File grew - append validated new lines only
1302
+ const newContent = currentContent.slice(originalLength);
1303
+ const validNewLines = newContent.split('\n')
1304
+ .filter(l => l.trim())
1305
+ .filter(l => {
1306
+ try {
1307
+ JSON.parse(l);
1308
+ return true;
1309
+ }
1310
+ catch {
1311
+ return false;
1312
+ }
1313
+ });
1314
+ const validatedNewContent = validNewLines.length > 0 ? validNewLines.join('\n') + '\n' : '';
1315
+ fs.writeFileSync(tempPath, keptContent + validatedNewContent);
1316
+ }
1317
+ else {
1318
+ fs.writeFileSync(tempPath, keptContent);
1319
+ }
1320
+ }
1321
+ catch {
1322
+ fs.writeFileSync(tempPath, keptContent);
1323
+ }
1324
+ fs.renameSync(tempPath, filePath);
1325
+ return { cleaned };
1326
+ }
1327
+ function emergencyEvict(filePath) {
1328
+ const result = evictToTarget(filePath, 100);
1329
+ return { success: result.success, evicted: result.evicted };
1330
+ }
1331
+ /**
1332
+ * Get eviction stats
1333
+ */
1334
+ function getEvictionStats() {
1335
+ try {
1336
+ if (!fs.existsSync(STATS_LOG))
1337
+ return { entries: 0 };
1338
+ const log = JSON.parse(fs.readFileSync(STATS_LOG, 'utf-8'));
1339
+ return {
1340
+ entries: log.length,
1341
+ lastEviction: log[log.length - 1]?.timestamp,
1342
+ };
1343
+ }
1344
+ catch {
1345
+ return { entries: 0 };
1346
+ }
1347
+ }
1348
+ /**
1349
+ * Get evicted content for retrieval (used by ccDNA)
1350
+ */
1351
+ function getEvictedContent(limit = 10) {
1352
+ try {
1353
+ if (!fs.existsSync(EVICTED_STORE))
1354
+ return [];
1355
+ const content = fs.readFileSync(EVICTED_STORE, 'utf-8');
1356
+ const entries = content.split('\n').filter(l => l.trim());
1357
+ return entries.slice(-limit).map(e => {
1358
+ try {
1359
+ return JSON.parse(e);
1360
+ }
1361
+ catch {
1362
+ return null;
1363
+ }
1364
+ }).filter(Boolean);
1365
+ }
1366
+ catch {
1367
+ return [];
1368
+ }
1369
+ }