@leverageaiapps/locus 2.2.8 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-loop.d.ts.map +1 -1
- package/dist/agent/agent-loop.js +356 -51
- package/dist/agent/agent-loop.js.map +1 -1
- package/dist/agent/claude-client.d.ts.map +1 -1
- package/dist/agent/claude-client.js +16 -9
- package/dist/agent/claude-client.js.map +1 -1
- package/dist/agent/compaction.d.ts +33 -1
- package/dist/agent/compaction.d.ts.map +1 -1
- package/dist/agent/compaction.js +512 -48
- package/dist/agent/compaction.js.map +1 -1
- package/dist/agent/tool-defs.js +1 -1
- package/dist/agent/tool-defs.js.map +1 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/agent/worker-proxy.d.ts +7 -2
- package/dist/agent/worker-proxy.d.ts.map +1 -1
- package/dist/agent/worker-proxy.js +5 -2
- package/dist/agent/worker-proxy.js.map +1 -1
- package/dist/vortex-tunnel.d.ts.map +1 -1
- package/dist/vortex-tunnel.js +13 -1
- package/dist/vortex-tunnel.js.map +1 -1
- package/package.json +1 -1
package/dist/agent/compaction.js
CHANGED
|
@@ -2,15 +2,56 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Context compaction — token estimation, truncation, and context window management.
|
|
4
4
|
* Uses a provided chat function for summary generation.
|
|
5
|
+
*
|
|
6
|
+
* v2: Multi-stage map-reduce summarization, identifier preservation,
|
|
7
|
+
* file operations tracking, tool failure preservation, progressive trimming.
|
|
5
8
|
*/
|
|
9
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
12
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
13
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
14
|
+
}
|
|
15
|
+
Object.defineProperty(o, k2, desc);
|
|
16
|
+
}) : (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
o[k2] = m[k];
|
|
19
|
+
}));
|
|
20
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
21
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
22
|
+
}) : function(o, v) {
|
|
23
|
+
o["default"] = v;
|
|
24
|
+
});
|
|
25
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
26
|
+
var ownKeys = function(o) {
|
|
27
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
28
|
+
var ar = [];
|
|
29
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
30
|
+
return ar;
|
|
31
|
+
};
|
|
32
|
+
return ownKeys(o);
|
|
33
|
+
};
|
|
34
|
+
return function (mod) {
|
|
35
|
+
if (mod && mod.__esModule) return mod;
|
|
36
|
+
var result = {};
|
|
37
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
38
|
+
__setModuleDefault(result, mod);
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
})();
|
|
6
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
43
|
exports.estimateTokens = estimateTokens;
|
|
8
44
|
exports.estimateMessagesTokens = estimateMessagesTokens;
|
|
9
45
|
exports.checkContextWindow = checkContextWindow;
|
|
46
|
+
exports.progressiveTrimToolResults = progressiveTrimToolResults;
|
|
10
47
|
exports.truncateOversizedToolResults = truncateOversizedToolResults;
|
|
11
48
|
exports.compactBrowserContext = compactBrowserContext;
|
|
12
49
|
exports.repairToolUseResultPairing = repairToolUseResultPairing;
|
|
13
50
|
exports.preCompactionMemoryFlush = preCompactionMemoryFlush;
|
|
51
|
+
exports.buildEnrichedSnippet = buildEnrichedSnippet;
|
|
52
|
+
exports.postTaskMemoryFlush = postTaskMemoryFlush;
|
|
53
|
+
exports.autoDistillMemory = autoDistillMemory;
|
|
54
|
+
exports.buildTranscriptDigest = buildTranscriptDigest;
|
|
14
55
|
exports.maybeCompactMessages = maybeCompactMessages;
|
|
15
56
|
exports.emergencyCompactMessages = emergencyCompactMessages;
|
|
16
57
|
// ─── Constants ───────────────────────────────────────────────────
|
|
@@ -20,6 +61,18 @@ const MIN_USABLE_CONTEXT = 8000;
|
|
|
20
61
|
const COMPACTION_THRESHOLD = 150000;
|
|
21
62
|
const MAX_TOOL_RESULT_CHARS = 30000;
|
|
22
63
|
const SAFETY_MARGIN = 1.2; // char/3 estimate can undercount; 20% buffer prevents overflow
|
|
64
|
+
// Multi-stage summarization
|
|
65
|
+
const SUMMARY_PART_MAX_TOKENS = 3000;
|
|
66
|
+
const SUMMARY_MERGE_MAX_TOKENS = 4000;
|
|
67
|
+
const MSG_CONDENSE_CHARS = 800; // per-message truncation for summarization input
|
|
68
|
+
// Identifier preservation instructions
|
|
69
|
+
const IDENTIFIER_INSTRUCTIONS = `CRITICAL: Preserve all opaque identifiers EXACTLY as written — never shorten, reconstruct, or paraphrase:
|
|
70
|
+
- UUIDs, hashes, commit SHAs
|
|
71
|
+
- File paths and URLs
|
|
72
|
+
- API keys, tokens, hostnames, IPs, ports
|
|
73
|
+
- Variable names, function names, class names
|
|
74
|
+
- Version numbers, error codes`;
|
|
75
|
+
const SUMMARY_SYSTEM_PROMPT = `You are a concise summarizer. Output only the summary, no preamble.\n\n${IDENTIFIER_INSTRUCTIONS}`;
|
|
23
76
|
// ─── Token Estimation ────────────────────────────────────────────
|
|
24
77
|
function estimateTokens(text) {
|
|
25
78
|
return Math.ceil(text.length / 3);
|
|
@@ -72,6 +125,41 @@ function checkContextWindow(messages, systemPromptTokens = 0) {
|
|
|
72
125
|
}
|
|
73
126
|
return { ok: true, warn: false, availableTokens: available, totalTokens };
|
|
74
127
|
}
|
|
128
|
+
// ─── Progressive Tool Result Trimming ────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Age-based gradual trimming of old tool results.
|
|
131
|
+
* Runs every turn BEFORE the hard truncation pass, freeing context space
|
|
132
|
+
* progressively instead of waiting for the compaction threshold.
|
|
133
|
+
*/
|
|
134
|
+
function progressiveTrimToolResults(messages) {
|
|
135
|
+
const len = messages.length;
|
|
136
|
+
let trimmed = 0;
|
|
137
|
+
for (let i = 0; i < len; i++) {
|
|
138
|
+
const age = len - i; // distance from newest message
|
|
139
|
+
let limit;
|
|
140
|
+
if (age > 16)
|
|
141
|
+
limit = 1500; // 8+ turns ago: aggressive
|
|
142
|
+
else if (age > 8)
|
|
143
|
+
limit = 3000; // 4+ turns ago: moderate
|
|
144
|
+
else
|
|
145
|
+
continue; // recent: don't touch
|
|
146
|
+
const blocks = Array.isArray(messages[i].content) ? messages[i].content : [];
|
|
147
|
+
for (const block of blocks) {
|
|
148
|
+
if (block.type !== 'tool_result')
|
|
149
|
+
continue;
|
|
150
|
+
if (typeof block.content === 'string' && block.content.length > limit) {
|
|
151
|
+
const head = Math.floor(limit * 0.7);
|
|
152
|
+
const tail = Math.floor(limit * 0.2);
|
|
153
|
+
const omitted = block.content.length - head - tail;
|
|
154
|
+
block.content = block.content.substring(0, head) +
|
|
155
|
+
`\n[... ${omitted} chars trimmed ...]\n` +
|
|
156
|
+
block.content.substring(block.content.length - tail);
|
|
157
|
+
trimmed++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return trimmed;
|
|
162
|
+
}
|
|
75
163
|
// ─── Truncate Oversized Tool Results ─────────────────────────────
|
|
76
164
|
function truncateOversizedToolResults(messages, maxChars = MAX_TOOL_RESULT_CHARS) {
|
|
77
165
|
let truncated = 0;
|
|
@@ -223,14 +311,27 @@ function repairToolUseResultPairing(messages) {
|
|
|
223
311
|
return repaired;
|
|
224
312
|
}
|
|
225
313
|
// ─── Pre-Compaction Memory Flush ─────────────────────────────────
|
|
226
|
-
let
|
|
227
|
-
const
|
|
314
|
+
let lastFlushCompactionCount = -1;
|
|
315
|
+
const FLUSH_SYSTEM_PROMPT = 'Pre-compaction memory flush turn. The session is near auto-compaction; capture durable memories to disk. Be concise — bullet points only.';
|
|
316
|
+
const FLUSH_USER_PROMPT_TEMPLATE = `Pre-compaction memory flush.
|
|
317
|
+
Store durable memories now to today's daily log.
|
|
318
|
+
IMPORTANT: Extract ONLY information worth preserving long-term:
|
|
319
|
+
- User preferences, decisions, persistent instructions
|
|
320
|
+
- Project details, architecture decisions, technical context
|
|
321
|
+
- People, dates, facts the user mentioned
|
|
322
|
+
- Anything the user asked to remember
|
|
323
|
+
If nothing worth saving, respond with "NONE".
|
|
324
|
+
|
|
325
|
+
Conversation:
|
|
326
|
+
`;
|
|
228
327
|
/**
|
|
229
328
|
* Before compaction discards old messages, extract any memory-worthy
|
|
230
329
|
* information and save it to the daily log.
|
|
330
|
+
* Uses compactionCount event tracking (more reliable than time-based cooldown).
|
|
231
331
|
*/
|
|
232
|
-
async function preCompactionMemoryFlush(messages, chatFn, writeToMemory) {
|
|
233
|
-
if
|
|
332
|
+
async function preCompactionMemoryFlush(messages, chatFn, writeToMemory, compactionCount = 0) {
|
|
333
|
+
// Event-based dedup: skip if already flushed for this compaction
|
|
334
|
+
if (lastFlushCompactionCount === compactionCount)
|
|
234
335
|
return;
|
|
235
336
|
try {
|
|
236
337
|
const parts = [];
|
|
@@ -247,18 +348,128 @@ async function preCompactionMemoryFlush(messages, chatFn, writeToMemory) {
|
|
|
247
348
|
const conversationSnippet = parts.join('\n').substring(0, 8000);
|
|
248
349
|
const resp = await chatFn([{
|
|
249
350
|
role: 'user',
|
|
250
|
-
content:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
351
|
+
content: FLUSH_USER_PROMPT_TEMPLATE + conversationSnippet,
|
|
352
|
+
}], {
|
|
353
|
+
systemPrompt: FLUSH_SYSTEM_PROMPT,
|
|
354
|
+
tools: [],
|
|
355
|
+
maxTokens: 1000,
|
|
356
|
+
});
|
|
357
|
+
const extracted = resp.content
|
|
358
|
+
.filter((b) => b.type === 'text')
|
|
359
|
+
.map((b) => b.text)
|
|
360
|
+
.join('');
|
|
361
|
+
if (extracted && !extracted.trim().toUpperCase().startsWith('NONE')) {
|
|
362
|
+
await writeToMemory(extracted);
|
|
363
|
+
console.log(`[Compaction] Memory flush: saved ${extracted.length} chars before compaction #${compactionCount}`);
|
|
364
|
+
}
|
|
365
|
+
lastFlushCompactionCount = compactionCount;
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
console.warn(`[Compaction] Memory flush failed: ${err.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// ─── Post-Task Memory Flush ──────────────────────────────────────
|
|
372
|
+
const POST_TASK_SYSTEM_PROMPT = `You are a memory extractor. After a task completes, extract key information worth remembering for FUTURE tasks.
|
|
373
|
+
|
|
374
|
+
Extract ONLY durable, reusable information:
|
|
375
|
+
- What was accomplished (files created/modified, commands run)
|
|
376
|
+
- What tools/commands were used and key approaches
|
|
377
|
+
- Successful approaches & lessons learned
|
|
378
|
+
- Failed approaches & problems solved
|
|
379
|
+
- Packages/libraries installed
|
|
380
|
+
- Environment changes
|
|
381
|
+
- User preferences or decisions expressed during the task
|
|
255
382
|
|
|
256
|
-
|
|
383
|
+
Format: concise bullet points, one line each.
|
|
384
|
+
IMPORTANT: Never include API keys, passwords, tokens, secrets, or credentials in the output.
|
|
385
|
+
If the conversation is trivial (simple greeting, one-word answer, etc.), respond with "NONE".
|
|
386
|
+
|
|
387
|
+
Conversation:
|
|
388
|
+
`;
|
|
389
|
+
const POST_TASK_USER_PROMPT = `Post-task memory flush.
|
|
390
|
+
A task just completed. Extract key information worth remembering for FUTURE tasks.
|
|
391
|
+
Be concise — bullet points only.
|
|
257
392
|
|
|
258
393
|
Conversation:
|
|
259
|
-
|
|
394
|
+
`;
|
|
395
|
+
// ─── Sensitive Data Redaction ────────────────────────────────────
|
|
396
|
+
const SENSITIVE_PATTERNS = [
|
|
397
|
+
[/(?:api[_-]?key|token|secret|password|access[_-]?key|auth)\s*[:=]\s*['"]?[\w\-./+]{20,}['"]?/gi, '[REDACTED_CREDENTIAL]'],
|
|
398
|
+
[/Bearer\s+[\w\-./+]{20,}/gi, '[REDACTED_BEARER]'],
|
|
399
|
+
[/(?:AKIA|ASIA)[A-Z0-9]{16}/g, '[REDACTED_AWS_KEY]'],
|
|
400
|
+
[/[A-Za-z0-9+/]{60,}={0,2}/g, '[REDACTED_BASE64]'],
|
|
401
|
+
[/-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----[\s\S]*?-----END/gi, '[REDACTED_PRIVATE_KEY]'],
|
|
402
|
+
[/(?:mongodb|postgres|mysql|redis):\/\/[^\s]*:[^\s@]*@/gi, '[REDACTED_CONN_STRING]'],
|
|
403
|
+
];
|
|
404
|
+
function redactSensitiveContent(text) {
|
|
405
|
+
let result = text;
|
|
406
|
+
for (const [pattern, replacement] of SENSITIVE_PATTERNS) {
|
|
407
|
+
result = result.replace(pattern, replacement);
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
/** Summarize tool input for memory flush (concise but informative) */
|
|
412
|
+
function summarizeToolInputForFlush(toolName, input) {
|
|
413
|
+
if (!input)
|
|
414
|
+
return '';
|
|
415
|
+
switch (toolName) {
|
|
416
|
+
case 'bash': return (input.command || '').substring(0, 200);
|
|
417
|
+
case 'write_file': return `${input.path || ''} (${(input.content || '').length} chars)`;
|
|
418
|
+
case 'read_file':
|
|
419
|
+
case 'show_file': return input.path || '';
|
|
420
|
+
case 'search_files': return `"${input.pattern || ''}" in ${input.directory || '.'}`;
|
|
421
|
+
case 'save_memory': return `${input.file ? input.file + ': ' : ''}${(input.content || '').substring(0, 80)}`;
|
|
422
|
+
case 'search_memory': return `query="${input.query || ''}"`;
|
|
423
|
+
default: return JSON.stringify(input).substring(0, 150);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** Build enriched conversation snippet including tool call details */
|
|
427
|
+
function buildEnrichedSnippet(messages, maxChars = 12000) {
|
|
428
|
+
const parts = [];
|
|
429
|
+
for (const msg of messages) {
|
|
430
|
+
if (typeof msg.content === 'string') {
|
|
431
|
+
if (msg.content)
|
|
432
|
+
parts.push(`${msg.role}: ${msg.content.substring(0, 500)}`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!Array.isArray(msg.content))
|
|
436
|
+
continue;
|
|
437
|
+
const msgParts = [];
|
|
438
|
+
for (const block of msg.content) {
|
|
439
|
+
if (block.type === 'text' && block.text) {
|
|
440
|
+
msgParts.push(block.text.substring(0, 400));
|
|
441
|
+
}
|
|
442
|
+
else if (block.type === 'tool_use' && block.name) {
|
|
443
|
+
msgParts.push(`[Tool: ${block.name}] ${summarizeToolInputForFlush(block.name, block.input)}`);
|
|
444
|
+
}
|
|
445
|
+
else if (block.type === 'tool_result') {
|
|
446
|
+
const resultText = typeof block.content === 'string' ? block.content : '';
|
|
447
|
+
const prefix = block.is_error ? '[Error] ' : '[Result] ';
|
|
448
|
+
const firstLine = resultText.split('\n').find((l) => l.trim()) || '';
|
|
449
|
+
msgParts.push(`${prefix}${firstLine.substring(0, 150)}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (msgParts.length > 0)
|
|
453
|
+
parts.push(`${msg.role}: ${msgParts.join(' | ')}`);
|
|
454
|
+
}
|
|
455
|
+
return redactSensitiveContent(parts.join('\n').substring(0, maxChars));
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* After a task completes, extract key information from the conversation
|
|
459
|
+
* and save it to the daily memory log.
|
|
460
|
+
* Ported from container-agent — adapted to use ChatFn instead of callClaudeDirect.
|
|
461
|
+
*/
|
|
462
|
+
async function postTaskMemoryFlush(messages, chatFn, writeToMemory) {
|
|
463
|
+
// Skip if conversation is too short to be worth saving
|
|
464
|
+
if (messages.length < 3)
|
|
465
|
+
return;
|
|
466
|
+
try {
|
|
467
|
+
const conversationSnippet = buildEnrichedSnippet(messages, 12000);
|
|
468
|
+
const resp = await chatFn([{
|
|
469
|
+
role: 'user',
|
|
470
|
+
content: POST_TASK_USER_PROMPT + conversationSnippet,
|
|
260
471
|
}], {
|
|
261
|
-
systemPrompt:
|
|
472
|
+
systemPrompt: POST_TASK_SYSTEM_PROMPT,
|
|
262
473
|
tools: [],
|
|
263
474
|
maxTokens: 1000,
|
|
264
475
|
});
|
|
@@ -267,16 +478,244 @@ ${conversationSnippet}`,
|
|
|
267
478
|
.map((b) => b.text)
|
|
268
479
|
.join('');
|
|
269
480
|
if (extracted && !extracted.trim().toUpperCase().startsWith('NONE')) {
|
|
270
|
-
|
|
271
|
-
|
|
481
|
+
let safeExtracted = redactSensitiveContent(extracted);
|
|
482
|
+
// Append file artifact info extracted from tool_use blocks
|
|
483
|
+
const fileArtifacts = [];
|
|
484
|
+
for (const msg of messages) {
|
|
485
|
+
if (typeof msg.content !== 'string' && Array.isArray(msg.content)) {
|
|
486
|
+
for (const block of msg.content) {
|
|
487
|
+
if (block.type === 'tool_use' && (block.name === 'write_file' || block.name === 'show_file')) {
|
|
488
|
+
const path = block.input?.path || block.input?.file_path || '';
|
|
489
|
+
if (path)
|
|
490
|
+
fileArtifacts.push(path);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (fileArtifacts.length > 0) {
|
|
496
|
+
safeExtracted += `\n- Files created: ${fileArtifacts.join(', ')}`;
|
|
497
|
+
}
|
|
498
|
+
await writeToMemory(safeExtracted);
|
|
499
|
+
console.log(`[Memory] Post-task flush: saved ${safeExtracted.length} chars`);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
console.log('[Memory] Post-task flush: nothing worth saving');
|
|
272
503
|
}
|
|
273
|
-
lastFlushTime = Date.now();
|
|
274
504
|
}
|
|
275
505
|
catch (err) {
|
|
276
|
-
console.warn(`[
|
|
506
|
+
console.warn(`[Memory] Post-task flush failed: ${err.message}`);
|
|
277
507
|
}
|
|
278
508
|
}
|
|
279
|
-
// ───
|
|
509
|
+
// ─── Auto-Distill MEMORY.md ──────────────────────────────────────
|
|
510
|
+
const DISTILL_THRESHOLD_CHARS = 8000;
|
|
511
|
+
const DISTILL_COOLDOWN_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
512
|
+
let lastDistillTime = 0;
|
|
513
|
+
const DISTILL_SYSTEM_PROMPT = `You are a memory curator. Distill the user's MEMORY.md into a concise, well-organized document.
|
|
514
|
+
|
|
515
|
+
Rules:
|
|
516
|
+
- Keep ALL essential facts: project architecture, user preferences, environment details, key decisions
|
|
517
|
+
- Remove duplicates, outdated entries, and trivially obvious information
|
|
518
|
+
- Reorganize into clear sections with markdown headings
|
|
519
|
+
- Preserve all identifiers EXACTLY (file paths, URLs, version numbers, config values)
|
|
520
|
+
- Output ONLY the distilled MEMORY.md content, no preamble or explanation
|
|
521
|
+
- Target length: 40-60% of the original
|
|
522
|
+
- Never include API keys, passwords, tokens, secrets, or credentials`;
|
|
523
|
+
/**
|
|
524
|
+
* Auto-distill MEMORY.md when it exceeds the size threshold.
|
|
525
|
+
* Called fire-and-forget after post-task flush.
|
|
526
|
+
*
|
|
527
|
+
* Safety guards:
|
|
528
|
+
* - 4-hour cooldown between distillations
|
|
529
|
+
* - Output must be 100+ chars, smaller than original, >= 20% of original
|
|
530
|
+
*/
|
|
531
|
+
async function autoDistillMemory(memoryDir, chatFn, writeAndSync) {
|
|
532
|
+
if (Date.now() - lastDistillTime < DISTILL_COOLDOWN_MS)
|
|
533
|
+
return false;
|
|
534
|
+
try {
|
|
535
|
+
const { readFile } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
|
|
536
|
+
const content = await readFile(`${memoryDir}/MEMORY.md`, 'utf-8').catch(() => '');
|
|
537
|
+
if (!content || content.length <= DISTILL_THRESHOLD_CHARS)
|
|
538
|
+
return false;
|
|
539
|
+
console.log(`[Memory] MEMORY.md is ${content.length} chars (threshold: ${DISTILL_THRESHOLD_CHARS}), distilling...`);
|
|
540
|
+
const resp = await chatFn([{
|
|
541
|
+
role: 'user',
|
|
542
|
+
content: `Distill this MEMORY.md. Keep essential facts, remove duplicates and outdated info, reorganize clearly.\n\nMEMORY.md content:\n${content}`,
|
|
543
|
+
}], {
|
|
544
|
+
systemPrompt: DISTILL_SYSTEM_PROMPT,
|
|
545
|
+
tools: [],
|
|
546
|
+
maxTokens: 4000,
|
|
547
|
+
});
|
|
548
|
+
const distilled = resp.content
|
|
549
|
+
.filter((b) => b.type === 'text')
|
|
550
|
+
.map((b) => b.text)
|
|
551
|
+
.join('');
|
|
552
|
+
// Safety guards
|
|
553
|
+
if (!distilled || distilled.length < 100) {
|
|
554
|
+
console.warn('[Memory] Distillation produced too little output, skipping');
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
if (distilled.length >= content.length) {
|
|
558
|
+
console.warn('[Memory] Distillation did not reduce size, skipping');
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
if (distilled.length < content.length * 0.2) {
|
|
562
|
+
console.warn(`[Memory] Distillation removed too much (${distilled.length}/${content.length}), skipping`);
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
const safeDistilled = redactSensitiveContent(distilled);
|
|
566
|
+
await writeAndSync(safeDistilled);
|
|
567
|
+
lastDistillTime = Date.now();
|
|
568
|
+
console.log(`[Memory] MEMORY.md distilled: ${content.length} → ${safeDistilled.length} chars (${Math.round(safeDistilled.length / content.length * 100)}%)`);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
console.warn(`[Memory] Auto-distill failed: ${err.message}`);
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// ─── Transcript Digest ───────────────────────────────────────────
|
|
577
|
+
/** Build a lightweight transcript digest for daily log */
|
|
578
|
+
function buildTranscriptDigest(messages, userMessage, turn, usage) {
|
|
579
|
+
const toolCounts = {};
|
|
580
|
+
let lastAssistantText = '';
|
|
581
|
+
for (const msg of messages) {
|
|
582
|
+
if (msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.trim()) {
|
|
583
|
+
lastAssistantText = msg.content;
|
|
584
|
+
}
|
|
585
|
+
if (!Array.isArray(msg.content))
|
|
586
|
+
continue;
|
|
587
|
+
for (const block of msg.content) {
|
|
588
|
+
if (block.type === 'tool_use' && block.name) {
|
|
589
|
+
toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
|
|
590
|
+
}
|
|
591
|
+
if (msg.role === 'assistant' && block.type === 'text' && block.text) {
|
|
592
|
+
lastAssistantText = block.text;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const actions = Object.entries(toolCounts)
|
|
597
|
+
.map(([name, count]) => `${name}×${count}`)
|
|
598
|
+
.join(', ');
|
|
599
|
+
const userSnippet = userMessage.substring(0, 200).replace(/\n/g, ' ');
|
|
600
|
+
const resultSnippet = lastAssistantText.substring(0, 200).replace(/\n/g, ' ');
|
|
601
|
+
return [
|
|
602
|
+
`**User:** ${userSnippet}`,
|
|
603
|
+
actions ? `**Actions:** ${actions}` : '',
|
|
604
|
+
resultSnippet ? `**Result:** ${resultSnippet}` : '',
|
|
605
|
+
`**Turns:** ${turn}${usage ? ` | Tokens: in=${usage.input_tokens} out=${usage.output_tokens}` : ''}`,
|
|
606
|
+
].filter(Boolean).join('\n');
|
|
607
|
+
}
|
|
608
|
+
// ─── Compaction Helpers ─────────────────────────────────────────
|
|
609
|
+
/** Condense messages into summarization-friendly text parts */
|
|
610
|
+
function condenseMsgsForSummary(msgs) {
|
|
611
|
+
const parts = [];
|
|
612
|
+
for (const msg of msgs) {
|
|
613
|
+
const content = typeof msg.content === 'string'
|
|
614
|
+
? msg.content
|
|
615
|
+
: msg.content.map((b) => {
|
|
616
|
+
if (b.type === 'text')
|
|
617
|
+
return b.text;
|
|
618
|
+
if (b.type === 'tool_use')
|
|
619
|
+
return `[Used ${b.name}]`;
|
|
620
|
+
if (b.type === 'tool_result') {
|
|
621
|
+
const text = typeof b.content === 'string' ? b.content : '';
|
|
622
|
+
const prefix = b.is_error ? '[Error: ' : '[Result: ';
|
|
623
|
+
return `${prefix}${text.substring(0, 200)}]`;
|
|
624
|
+
}
|
|
625
|
+
return '';
|
|
626
|
+
}).filter(Boolean).join(' ');
|
|
627
|
+
if (content)
|
|
628
|
+
parts.push(`${msg.role}: ${content.substring(0, MSG_CONDENSE_CHARS)}`);
|
|
629
|
+
}
|
|
630
|
+
return parts;
|
|
631
|
+
}
|
|
632
|
+
/** Find the split point that divides messages into ~50% token share */
|
|
633
|
+
function findSplitPoint(messages) {
|
|
634
|
+
const total = estimateMessagesTokens(messages);
|
|
635
|
+
const half = total / 2;
|
|
636
|
+
let running = 0;
|
|
637
|
+
for (let i = 0; i < messages.length; i++) {
|
|
638
|
+
running += estimateMessagesTokens([messages[i]]);
|
|
639
|
+
if (running >= half)
|
|
640
|
+
return Math.max(1, i);
|
|
641
|
+
}
|
|
642
|
+
return Math.max(1, Math.floor(messages.length / 2));
|
|
643
|
+
}
|
|
644
|
+
/** Extract file operations from messages for compaction metadata */
|
|
645
|
+
function extractFileOperations(messages) {
|
|
646
|
+
const read = new Set();
|
|
647
|
+
const modified = new Set();
|
|
648
|
+
for (const msg of messages) {
|
|
649
|
+
if (!Array.isArray(msg.content))
|
|
650
|
+
continue;
|
|
651
|
+
for (const block of msg.content) {
|
|
652
|
+
if (block.type !== 'tool_use')
|
|
653
|
+
continue;
|
|
654
|
+
const path = block.input?.path;
|
|
655
|
+
if (!path)
|
|
656
|
+
continue;
|
|
657
|
+
if (block.name === 'read_file' || block.name === 'show_file')
|
|
658
|
+
read.add(path);
|
|
659
|
+
if (block.name === 'write_file')
|
|
660
|
+
modified.add(path);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
readFiles: [...read].slice(0, 30),
|
|
665
|
+
modifiedFiles: [...modified].slice(0, 30),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/** Find the tool name that matches a given tool_use_id */
|
|
669
|
+
function findToolNameForResult(messages, toolUseId) {
|
|
670
|
+
for (const msg of messages) {
|
|
671
|
+
if (!Array.isArray(msg.content))
|
|
672
|
+
continue;
|
|
673
|
+
for (const block of msg.content) {
|
|
674
|
+
if (block.type === 'tool_use' && block.id === toolUseId)
|
|
675
|
+
return block.name;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
/** Extract tool failures from messages for compaction metadata */
|
|
681
|
+
function extractToolFailures(messages) {
|
|
682
|
+
const failures = [];
|
|
683
|
+
for (const msg of messages) {
|
|
684
|
+
if (!Array.isArray(msg.content))
|
|
685
|
+
continue;
|
|
686
|
+
for (const block of msg.content) {
|
|
687
|
+
if (block.type !== 'tool_result' || !block.is_error)
|
|
688
|
+
continue;
|
|
689
|
+
const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content || '');
|
|
690
|
+
const toolName = findToolNameForResult(messages, block.tool_use_id) || 'unknown';
|
|
691
|
+
failures.push({ tool: toolName, error: content.substring(0, 200) });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return failures.slice(-8); // Keep last 8 failures
|
|
695
|
+
}
|
|
696
|
+
/** Format metadata (file ops + failures) as text to append to summary */
|
|
697
|
+
function formatMetadata(fileOps, failures) {
|
|
698
|
+
const sections = [];
|
|
699
|
+
if (fileOps.readFiles.length > 0) {
|
|
700
|
+
sections.push(`<read-files>\n${fileOps.readFiles.join(', ')}\n</read-files>`);
|
|
701
|
+
}
|
|
702
|
+
if (fileOps.modifiedFiles.length > 0) {
|
|
703
|
+
sections.push(`<modified-files>\n${fileOps.modifiedFiles.join(', ')}\n</modified-files>`);
|
|
704
|
+
}
|
|
705
|
+
if (failures.length > 0) {
|
|
706
|
+
const items = failures.map(f => `- ${f.tool}: "${f.error}"`).join('\n');
|
|
707
|
+
sections.push(`<tool-failures>\n${items}\n</tool-failures>`);
|
|
708
|
+
}
|
|
709
|
+
return sections.length > 0 ? '\n\n' + sections.join('\n') : '';
|
|
710
|
+
}
|
|
711
|
+
/** Extract text from Claude response */
|
|
712
|
+
function extractText(resp) {
|
|
713
|
+
return resp.content
|
|
714
|
+
.filter((b) => b.type === 'text')
|
|
715
|
+
.map((b) => b.text)
|
|
716
|
+
.join('');
|
|
717
|
+
}
|
|
718
|
+
// ─── Maybe Compact Messages (Multi-Stage) ────────────────────────
|
|
280
719
|
async function maybeCompactMessages(messages, chatFn, systemPrompt) {
|
|
281
720
|
const rawTokens = estimateMessagesTokens(messages);
|
|
282
721
|
const safeTokens = Math.ceil(rawTokens * SAFETY_MARGIN);
|
|
@@ -289,38 +728,52 @@ async function maybeCompactMessages(messages, chatFn, systemPrompt) {
|
|
|
289
728
|
const recentMessages = messages.slice(messages.length - keepCount);
|
|
290
729
|
if (oldMessages.length === 0)
|
|
291
730
|
return messages;
|
|
292
|
-
//
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
: msg.content.map((b) => {
|
|
298
|
-
if (b.type === 'text')
|
|
299
|
-
return b.text;
|
|
300
|
-
if (b.type === 'tool_use')
|
|
301
|
-
return `[Used ${b.name}]`;
|
|
302
|
-
if (b.type === 'tool_result')
|
|
303
|
-
return `[Result: ${(typeof b.content === 'string' ? b.content : '').substring(0, 200)}]`;
|
|
304
|
-
return '';
|
|
305
|
-
}).filter(Boolean).join(' ');
|
|
306
|
-
if (content)
|
|
307
|
-
summaryParts.push(`${msg.role}: ${content.substring(0, 500)}`);
|
|
308
|
-
}
|
|
309
|
-
// Ask Claude to summarize via provided chat function
|
|
731
|
+
// Extract metadata before summarization
|
|
732
|
+
const fileOps = extractFileOperations(oldMessages);
|
|
733
|
+
const failures = extractToolFailures(oldMessages);
|
|
734
|
+
const metadata = formatMetadata(fileOps, failures);
|
|
735
|
+
// Try multi-stage map-reduce summarization
|
|
310
736
|
try {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
.
|
|
318
|
-
|
|
319
|
-
|
|
737
|
+
let summaryText;
|
|
738
|
+
if (oldMessages.length >= 6) {
|
|
739
|
+
// Split into 2 parts by token share and summarize in parallel
|
|
740
|
+
const mid = findSplitPoint(oldMessages);
|
|
741
|
+
const part1 = condenseMsgsForSummary(oldMessages.slice(0, mid));
|
|
742
|
+
const part2 = condenseMsgsForSummary(oldMessages.slice(mid));
|
|
743
|
+
console.log(`[Compaction] Map-reduce: part1=${part1.length} msgs, part2=${oldMessages.length - mid} msgs`);
|
|
744
|
+
const [resp1, resp2] = await Promise.all([
|
|
745
|
+
chatFn([{ role: 'user', content: `Summarize this conversation segment concisely (key decisions, actions taken, results, current state):\n\n${part1.join('\n\n')}` }], { systemPrompt: SUMMARY_SYSTEM_PROMPT, tools: [], maxTokens: SUMMARY_PART_MAX_TOKENS }),
|
|
746
|
+
chatFn([{ role: 'user', content: `Summarize this conversation segment concisely (key decisions, actions taken, results, current state):\n\n${part2.join('\n\n')}` }], { systemPrompt: SUMMARY_SYSTEM_PROMPT, tools: [], maxTokens: SUMMARY_PART_MAX_TOKENS }),
|
|
747
|
+
]);
|
|
748
|
+
const summary1 = extractText(resp1);
|
|
749
|
+
const summary2 = extractText(resp2);
|
|
750
|
+
if (summary1 && summary2) {
|
|
751
|
+
// Merge the two partial summaries
|
|
752
|
+
try {
|
|
753
|
+
const mergeResp = await chatFn([{ role: 'user', content: `Merge these two partial conversation summaries into one cohesive summary.\nPreserve: key decisions, current state, file paths, identifiers, TODOs, open questions.\n\n--- Part 1 ---\n${summary1}\n\n--- Part 2 ---\n${summary2}` }], { systemPrompt: SUMMARY_SYSTEM_PROMPT, tools: [], maxTokens: SUMMARY_MERGE_MAX_TOKENS });
|
|
754
|
+
summaryText = extractText(mergeResp) || `${summary1}\n\n${summary2}`;
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// Merge failed — concatenate as fallback
|
|
758
|
+
summaryText = `${summary1}\n\n${summary2}`;
|
|
759
|
+
console.warn('[Compaction] Merge failed, concatenating partial summaries');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
summaryText = summary1 || summary2 || '';
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
// Too few messages for map-reduce — single pass with enhanced prompt
|
|
768
|
+
const parts = condenseMsgsForSummary(oldMessages);
|
|
769
|
+
const resp = await chatFn([{ role: 'user', content: `Summarize this conversation history concisely (key decisions, actions taken, results, current state):\n\n${parts.join('\n\n')}` }], { systemPrompt: SUMMARY_SYSTEM_PROMPT, tools: [], maxTokens: SUMMARY_MERGE_MAX_TOKENS });
|
|
770
|
+
summaryText = extractText(resp);
|
|
771
|
+
}
|
|
320
772
|
if (summaryText) {
|
|
321
|
-
|
|
773
|
+
const enrichedSummary = summaryText + metadata;
|
|
774
|
+
console.log(`[Compaction] Summarized ${oldMessages.length} messages into ${enrichedSummary.length} chars (${metadata ? 'with' : 'no'} metadata)`);
|
|
322
775
|
return [
|
|
323
|
-
{ role: 'user', content: `[Previous conversation summary]\n${
|
|
776
|
+
{ role: 'user', content: `[Previous conversation summary]\n${enrichedSummary}` },
|
|
324
777
|
{ role: 'assistant', content: 'Understood, I have the context from our previous conversation. How can I continue helping?' },
|
|
325
778
|
...recentMessages,
|
|
326
779
|
];
|
|
@@ -329,7 +782,14 @@ async function maybeCompactMessages(messages, chatFn, systemPrompt) {
|
|
|
329
782
|
catch (err) {
|
|
330
783
|
console.error(`[Compaction] Summary failed: ${err.message}`);
|
|
331
784
|
}
|
|
332
|
-
// Fallback: just truncate old messages
|
|
785
|
+
// Fallback: just truncate old messages, but still append metadata
|
|
786
|
+
if (metadata) {
|
|
787
|
+
return [
|
|
788
|
+
{ role: 'user', content: `[Previous conversation was compacted — details lost]${metadata}` },
|
|
789
|
+
{ role: 'assistant', content: 'Understood. Continuing from the recent context.' },
|
|
790
|
+
...recentMessages,
|
|
791
|
+
];
|
|
792
|
+
}
|
|
333
793
|
return recentMessages;
|
|
334
794
|
}
|
|
335
795
|
// ─── Emergency Compact ───────────────────────────────────────────
|
|
@@ -339,10 +799,14 @@ function emergencyCompactMessages(messages) {
|
|
|
339
799
|
// Keep first message + last 4 messages
|
|
340
800
|
const first = messages[0];
|
|
341
801
|
const recent = messages.slice(-4);
|
|
802
|
+
// Extract file ops metadata even in emergency
|
|
803
|
+
const dropped = messages.slice(1, -4);
|
|
804
|
+
const fileOps = extractFileOperations(dropped);
|
|
805
|
+
const metadata = formatMetadata(fileOps, []);
|
|
342
806
|
console.log(`[Compaction] Emergency: dropped ${messages.length - 5} middle messages`);
|
|
343
807
|
return [
|
|
344
808
|
first,
|
|
345
|
-
{ role: 'user', content:
|
|
809
|
+
{ role: 'user', content: `[Earlier conversation messages were removed due to context limits. Continue from the most recent context.]${metadata}` },
|
|
346
810
|
{ role: 'assistant', content: 'Understood. Continuing from the recent context.' },
|
|
347
811
|
...recent,
|
|
348
812
|
];
|