@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.
@@ -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 lastFlushTime = 0;
227
- const FLUSH_COOLDOWN_MS = 60000;
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 (Date.now() - lastFlushTime < FLUSH_COOLDOWN_MS)
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: `Extract any information from this conversation that should be saved to long-term memory. Focus on:
251
- - User preferences, decisions, or persistent instructions
252
- - Project details, architecture decisions, or technical context
253
- - People, dates, or facts the user mentioned
254
- - Anything the user asked to remember
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
- If there's nothing worth saving, respond with "NONE".
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
- ${conversationSnippet}`,
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: 'You extract memory-worthy information from conversations. Output only the extracted memories as bullet points, or "NONE" if nothing is worth saving. Be concise.',
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
- await writeToMemory(extracted);
271
- console.log(`[Compaction] Memory flush: saved ${extracted.length} chars before compaction`);
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(`[Compaction] Memory flush failed: ${err.message}`);
506
+ console.warn(`[Memory] Post-task flush failed: ${err.message}`);
277
507
  }
278
508
  }
279
- // ─── Maybe Compact Messages ──────────────────────────────────────
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
- // Build summary of old messages
293
- const summaryParts = [];
294
- for (const msg of oldMessages) {
295
- const content = typeof msg.content === 'string'
296
- ? msg.content
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
- const summaryResp = await chatFn([{ role: 'user', content: `Summarize this conversation history concisely (key decisions, results, current state):\n\n${summaryParts.join('\n\n')}` }], {
312
- systemPrompt: 'You are a concise summarizer. Output only the summary, no preamble.',
313
- tools: [],
314
- maxTokens: 2000,
315
- });
316
- const summaryText = summaryResp.content
317
- .filter((b) => b.type === 'text')
318
- .map((b) => b.text)
319
- .join('');
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
- console.log(`[Compaction] Summarized ${oldMessages.length} messages into ${summaryText.length} chars`);
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${summaryText}` },
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: '[Earlier conversation messages were removed due to context limits. Continue from the most recent context.]' },
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
  ];