@ekkos/cli 0.2.17 → 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.
- package/LICENSE +21 -0
- package/dist/capture/eviction-client.d.ts +139 -0
- package/dist/capture/eviction-client.js +454 -0
- package/dist/capture/index.d.ts +2 -0
- package/dist/capture/index.js +2 -0
- package/dist/capture/jsonl-rewriter.d.ts +96 -0
- package/dist/capture/jsonl-rewriter.js +1369 -0
- package/dist/capture/transcript-repair.d.ts +50 -0
- package/dist/capture/transcript-repair.js +308 -0
- package/dist/commands/doctor.js +23 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +1229 -293
- package/dist/commands/setup.js +62 -16
- package/dist/commands/usage.d.ts +7 -0
- package/dist/commands/usage.js +214 -0
- package/dist/cron/index.d.ts +7 -0
- package/dist/cron/index.js +13 -0
- package/dist/cron/promoter.d.ts +70 -0
- package/dist/cron/promoter.js +403 -0
- package/dist/index.js +24 -3
- package/dist/lib/usage-monitor.d.ts +47 -0
- package/dist/lib/usage-monitor.js +124 -0
- package/dist/lib/usage-parser.d.ts +72 -0
- package/dist/lib/usage-parser.js +238 -0
- package/dist/restore/RestoreOrchestrator.d.ts +4 -0
- package/dist/restore/RestoreOrchestrator.js +118 -30
- package/package.json +12 -12
- package/templates/cursor-hooks/after-agent-response.sh +0 -0
- package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
- package/templates/cursor-hooks/stop.sh +0 -0
- package/templates/ekkos-manifest.json +2 -2
- package/templates/hooks/assistant-response.sh +0 -0
- package/templates/hooks/session-start.sh +0 -0
- package/templates/hooks/user-prompt-submit.sh +6 -0
- package/templates/plan-template.md +0 -0
- package/templates/spec-template.md +0 -0
- package/templates/agents/README.md +0 -182
- package/templates/agents/code-reviewer.md +0 -166
- package/templates/agents/debug-detective.md +0 -169
- package/templates/agents/ekkOS_Vercel.md +0 -99
- package/templates/agents/extension-manager.md +0 -229
- package/templates/agents/git-companion.md +0 -185
- package/templates/agents/github-test-agent.md +0 -321
- 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
|
+
}
|