claude-mneme 2.9.1

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.
@@ -0,0 +1,2142 @@
1
+ /**
2
+ * Shared utilities for claude-mneme plugin
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, statSync, unlinkSync, renameSync, openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
6
+ import { execFileSync, spawn } from 'child_process';
7
+ import { homedir } from 'os';
8
+ import { join, basename, dirname } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ export const MEMORY_BASE = join(homedir(), '.claude-mneme');
12
+ export const CONFIG_FILE = join(MEMORY_BASE, 'config.json');
13
+
14
+ /**
15
+ * Escape a string for use inside an XML/HTML attribute value.
16
+ */
17
+ export function escapeAttr(str) {
18
+ return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+ }
20
+
21
+ /**
22
+ * Get the project name from cwd
23
+ * Uses git repo root name if available, otherwise directory name
24
+ */
25
+ export function getProjectName(cwd = process.cwd()) {
26
+ try {
27
+ // Try to get git repo root using execFileSync (safer than execSync)
28
+ const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
29
+ encoding: 'utf8',
30
+ cwd,
31
+ stdio: ['ignore', 'pipe', 'ignore']
32
+ }).trim();
33
+ return basename(gitRoot);
34
+ } catch {
35
+ // Not a git repo, use directory name
36
+ return basename(cwd);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Get the project-specific memory directory
42
+ */
43
+ function getProjectMemoryDir(cwd = process.cwd()) {
44
+ const projectName = getProjectName(cwd);
45
+ // Sanitize project name for filesystem
46
+ const safeName = projectName.replace(/[^a-zA-Z0-9_-]/g, '_');
47
+ return join(MEMORY_BASE, 'projects', safeName);
48
+ }
49
+
50
+ /**
51
+ * Ensure memory directories exist and return paths
52
+ */
53
+ export function ensureMemoryDirs(cwd = process.cwd()) {
54
+ const projectDir = getProjectMemoryDir(cwd);
55
+
56
+ if (!existsSync(MEMORY_BASE)) {
57
+ mkdirSync(MEMORY_BASE, { recursive: true });
58
+ }
59
+
60
+ if (!existsSync(projectDir)) {
61
+ mkdirSync(projectDir, { recursive: true });
62
+ }
63
+
64
+ return {
65
+ base: MEMORY_BASE,
66
+ project: projectDir,
67
+ log: join(projectDir, 'log.jsonl'),
68
+ summary: join(projectDir, 'summary.md'),
69
+ summaryJson: join(projectDir, 'summary.json'),
70
+ remembered: join(projectDir, 'remembered.json'),
71
+ entities: join(projectDir, 'entities.json'),
72
+ cache: join(projectDir, '.cache.json'),
73
+ lastSession: join(projectDir, '.last-session'),
74
+ handoff: join(projectDir, 'handoff.json'),
75
+ config: CONFIG_FILE
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Acquire a file lock using O_EXCL, run fn, then release.
81
+ * If the lock is held by another process, returns undefined without running fn.
82
+ * Stale locks (older than staleSec) are automatically broken.
83
+ */
84
+ export function withFileLock(lockPath, fn, staleSec = 10) {
85
+ try {
86
+ const fd = openSync(lockPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
87
+ writeSync(fd, Buffer.from(process.pid.toString()));
88
+ closeSync(fd);
89
+ } catch (e) {
90
+ if (e.code !== 'EEXIST') throw e;
91
+ // Lock exists — check if stale
92
+ try {
93
+ if (Date.now() - statSync(lockPath).mtimeMs > staleSec * 1000) {
94
+ unlinkSync(lockPath);
95
+ // Retry once
96
+ const fd = openSync(lockPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
97
+ writeSync(fd, Buffer.from(process.pid.toString()));
98
+ closeSync(fd);
99
+ } else {
100
+ return undefined; // Lock held, skip
101
+ }
102
+ } catch {
103
+ return undefined; // Can't break stale lock or lost retry race, skip
104
+ }
105
+ }
106
+
107
+ try {
108
+ return fn();
109
+ } finally {
110
+ try { unlinkSync(lockPath); } catch {}
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Get file modification time, or 0 if file doesn't exist
116
+ */
117
+ function getFileMtime(filePath) {
118
+ try {
119
+ if (existsSync(filePath)) {
120
+ return statSync(filePath).mtimeMs;
121
+ }
122
+ } catch {}
123
+ return 0;
124
+ }
125
+
126
+ /**
127
+ * Read and cache parsed data from JSON files
128
+ * Uses file mtime to validate cache freshness
129
+ *
130
+ * @param {string} cwd - Working directory
131
+ * @param {object} config - Config object
132
+ * @returns {object} Cached data { summary, remembered, logEntries, entities }
133
+ */
134
+ export function readCachedData(cwd = process.cwd(), config = {}) {
135
+ const paths = ensureMemoryDirs(cwd);
136
+ const cacheConfig = config.caching || {};
137
+
138
+ if (cacheConfig.enabled === false) {
139
+ return readFreshData(paths);
140
+ }
141
+
142
+ const maxAgeMs = (cacheConfig.maxAgeSeconds || 60) * 1000;
143
+
144
+ // Check if cache exists and is fresh
145
+ if (existsSync(paths.cache)) {
146
+ try {
147
+ const cache = JSON.parse(readFileSync(paths.cache, 'utf-8'));
148
+ const cacheAge = Date.now() - (cache.cachedAt || 0);
149
+
150
+ // Validate cache: check age and source file mtimes
151
+ if (cacheAge < maxAgeMs) {
152
+ const summaryMtime = getFileMtime(paths.summaryJson);
153
+ const rememberedMtime = getFileMtime(paths.remembered);
154
+ const logMtime = getFileMtime(paths.log);
155
+ const entitiesMtime = getFileMtime(paths.entities);
156
+
157
+ const mtimesMatch =
158
+ cache.mtimes?.summary === summaryMtime &&
159
+ cache.mtimes?.remembered === rememberedMtime &&
160
+ cache.mtimes?.log === logMtime &&
161
+ cache.mtimes?.entities === entitiesMtime;
162
+
163
+ if (mtimesMatch) {
164
+ return cache.data;
165
+ }
166
+ }
167
+ } catch {
168
+ // Cache read failed, fall through to fresh read
169
+ }
170
+ }
171
+
172
+ // Cache miss or invalid - read fresh and update cache
173
+ const freshData = readFreshData(paths);
174
+
175
+ // Write cache
176
+ try {
177
+ const cache = {
178
+ cachedAt: Date.now(),
179
+ mtimes: {
180
+ summary: getFileMtime(paths.summaryJson),
181
+ remembered: getFileMtime(paths.remembered),
182
+ log: getFileMtime(paths.log),
183
+ entities: getFileMtime(paths.entities)
184
+ },
185
+ data: freshData
186
+ };
187
+ writeFileSync(paths.cache, JSON.stringify(cache));
188
+ } catch {
189
+ // Cache write failed, continue without caching
190
+ }
191
+
192
+ return freshData;
193
+ }
194
+
195
+ /**
196
+ * Read fresh data from source files (no caching)
197
+ */
198
+ function readFreshData(paths) {
199
+ const result = {
200
+ summary: null,
201
+ remembered: [],
202
+ logEntries: [],
203
+ entities: null
204
+ };
205
+
206
+ // Read summary
207
+ if (existsSync(paths.summaryJson)) {
208
+ try {
209
+ result.summary = JSON.parse(readFileSync(paths.summaryJson, 'utf-8'));
210
+ } catch (e) {
211
+ logError(e, 'readFreshData:summary.json');
212
+ }
213
+ }
214
+
215
+ // Read remembered
216
+ if (existsSync(paths.remembered)) {
217
+ try {
218
+ result.remembered = JSON.parse(readFileSync(paths.remembered, 'utf-8'));
219
+ } catch (e) {
220
+ logError(e, 'readFreshData:remembered.json');
221
+ }
222
+ }
223
+
224
+ // Read and parse log entries
225
+ if (existsSync(paths.log)) {
226
+ try {
227
+ const content = readFileSync(paths.log, 'utf-8').trim();
228
+ if (content) {
229
+ result.logEntries = content.split('\n')
230
+ .filter(l => l)
231
+ .map(line => {
232
+ try { return JSON.parse(line); }
233
+ catch { return null; }
234
+ })
235
+ .filter(Boolean);
236
+ }
237
+ } catch (e) {
238
+ logError(e, 'readFreshData:log.jsonl');
239
+ }
240
+ }
241
+
242
+ // Read entities
243
+ if (existsSync(paths.entities)) {
244
+ try {
245
+ result.entities = JSON.parse(readFileSync(paths.entities, 'utf-8'));
246
+ } catch (e) {
247
+ logError(e, 'readFreshData:entities.json');
248
+ }
249
+ }
250
+
251
+ return result;
252
+ }
253
+
254
+ /**
255
+ * Invalidate cache (call after writes)
256
+ */
257
+ export function invalidateCache(cwd = process.cwd()) {
258
+ const paths = ensureMemoryDirs(cwd);
259
+ try {
260
+ if (existsSync(paths.cache)) {
261
+ writeFileSync(paths.cache, '{}');
262
+ }
263
+ } catch (e) {
264
+ logError(e, 'invalidateCache');
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Recursively merge source into target, preserving nested default keys.
270
+ * Arrays and non-plain-object values from source replace target entirely.
271
+ */
272
+ function deepMerge(target, source) {
273
+ const result = { ...target };
274
+ for (const key of Object.keys(source)) {
275
+ const srcVal = source[key];
276
+ const tgtVal = target[key];
277
+ if (
278
+ srcVal && typeof srcVal === 'object' && !Array.isArray(srcVal) &&
279
+ tgtVal && typeof tgtVal === 'object' && !Array.isArray(tgtVal)
280
+ ) {
281
+ result[key] = deepMerge(tgtVal, srcVal);
282
+ } else {
283
+ result[key] = srcVal;
284
+ }
285
+ }
286
+ return result;
287
+ }
288
+
289
+ /**
290
+ * Load config with defaults (cached per process)
291
+ */
292
+ let _cachedConfig = null;
293
+ export function loadConfig() {
294
+ if (_cachedConfig) return _cachedConfig;
295
+
296
+ const defaultConfig = {
297
+ maxLogEntriesBeforeSummarize: 50,
298
+ keepRecentEntries: 10,
299
+ maxResponseLength: 1000,
300
+ responseSummarization: 'none',
301
+ maxSummarySentences: 6,
302
+ actionWords: [
303
+ 'fixed', 'added', 'created', 'updated', 'removed', 'deleted',
304
+ 'implemented', 'refactored', 'changed', 'modified', 'resolved',
305
+ 'installed', 'configured', 'migrated', 'moved', 'renamed',
306
+ 'error', 'bug', 'issue', 'warning', 'failed', 'success',
307
+ 'complete', 'done', 'finished', 'ready'
308
+ ],
309
+ reasoningWords: [
310
+ 'because', 'since', 'instead', 'rather', 'trade-off', 'tradeoff',
311
+ 'decided', 'decision', 'chose', 'chosen', 'approach',
312
+ "can't", "cannot", "won't", "shouldn't", "don't",
313
+ 'avoid', 'avoids', 'prevents', 'risk', 'concern',
314
+ 'alternative', 'option', 'prefer', 'preferred',
315
+ 'problem', 'constraint', 'limitation', 'blocker'
316
+ ],
317
+ model: 'claude-haiku-4-20250514',
318
+ claudePath: 'claude',
319
+
320
+ // PreCompact hook configuration
321
+ preCompact: {
322
+ enabled: true, // Enable/disable PreCompact hook
323
+ triggers: ['auto', 'manual'], // Which triggers to respond to
324
+ flushPending: true, // Flush pending log entries
325
+ forceSummarize: true, // Force immediate summarization
326
+ extractContext: true, // Extract context from transcript
327
+ saveSnapshot: false, // Save full transcript snapshot
328
+ extraction: {
329
+ enabled: true,
330
+ maxItems: 10, // Max items per category
331
+ categories: {
332
+ decisions: true, // Extract decisions/choices made
333
+ files: true, // Extract file paths mentioned
334
+ errors: true, // Extract errors encountered
335
+ todos: true, // Extract TODOs/action items
336
+ keyPoints: true // Extract key discussion points
337
+ }
338
+ }
339
+ },
340
+
341
+ // PostCompact hook configuration (injects extracted context after compaction)
342
+ postCompact: {
343
+ enabled: true, // Enable/disable context injection
344
+ maxAgeMinutes: 5, // Only inject if extraction is this recent
345
+ maxFiles: 10, // Max file paths to inject
346
+ categories: {
347
+ keyPoints: true, // Inject key discussion points
348
+ decisions: true, // Inject decisions made
349
+ files: true, // Inject file paths
350
+ errors: true, // Inject errors encountered
351
+ todos: true // Inject pending items
352
+ }
353
+ },
354
+
355
+ // Relevance-based injection configuration
356
+ relevanceScoring: {
357
+ enabled: true, // Enable/disable relevance scoring
358
+ maxEntries: 10, // Max entries to inject after scoring
359
+ weights: {
360
+ recency: 0.4, // Weight for time decay (0-1)
361
+ fileRelevance: 0.35, // Weight for file path matching (0-1)
362
+ typePriority: 0.25 // Weight for entry type priority (0-1)
363
+ },
364
+ typePriorities: { // Priority scores by entry type (higher = more important)
365
+ commit: 1.0,
366
+ task: 0.9,
367
+ agent: 0.8,
368
+ prompt: 0.5,
369
+ response: 0.3,
370
+ compact: 0.4
371
+ },
372
+ recencyHalfLifeHours: 24 // Hours until recency score drops to 50%
373
+ },
374
+
375
+ // Entity extraction and indexing configuration
376
+ entityExtraction: {
377
+ enabled: true, // Enable/disable entity extraction
378
+ maxContextsPerEntity: 5, // Max contexts to keep per entity
379
+ categories: {
380
+ files: true, // Extract file paths
381
+ functions: true, // Extract function/method names
382
+ errors: true, // Extract error messages
383
+ packages: true // Extract package names
384
+ },
385
+ // File extension filter - only index files with these extensions
386
+ fileExtensions: ['js', 'ts', 'jsx', 'tsx', 'mjs', 'py', 'rb', 'go', 'rs', 'java', 'cpp', 'c', 'h', 'css', 'scss', 'html', 'vue', 'svelte', 'json', 'yaml', 'yml', 'md', 'sql'],
387
+ // Minimum entity name length to index
388
+ minEntityLength: 2,
389
+ // Enable entity-based relevance boost
390
+ useInRelevanceScoring: true,
391
+ // Remove entities not seen in this many days (0 = never prune)
392
+ maxAgeDays: 30
393
+ },
394
+
395
+ // Semantic deduplication configuration
396
+ deduplication: {
397
+ enabled: true, // Enable/disable deduplication
398
+ timeWindowMinutes: 5, // Group entries within this time window
399
+ typePriority: { // Higher = more signal (kept over lower)
400
+ commit: 100,
401
+ task: 80,
402
+ agent: 70,
403
+ prompt: 40,
404
+ response: 30,
405
+ compact: 20
406
+ },
407
+ mergeContext: true // Include context from dropped entries in kept entry
408
+ },
409
+
410
+ // Outcome tracking configuration
411
+ outcomeTracking: {
412
+ enabled: true, // Enable/disable outcome tracking
413
+ outcomePriority: { // Score multiplier for task outcomes (0-1)
414
+ completed: 1.0, // Completed tasks are highest signal
415
+ in_progress: 0.7, // In-progress tasks are medium signal
416
+ abandoned: 0.3 // Abandoned tasks are low signal (but not zero)
417
+ },
418
+ trackDuration: true // Track how long tasks took
419
+ },
420
+
421
+ // File caching configuration
422
+ caching: {
423
+ enabled: true, // Enable/disable file caching
424
+ maxAgeSeconds: 60 // Cache validity in seconds
425
+ },
426
+
427
+ // Sync server configuration (optional)
428
+ sync: {
429
+ enabled: false, // Local-only by default
430
+ serverUrl: null, // e.g., "http://localhost:3847"
431
+ apiKey: null, // Optional authentication
432
+ projectId: null, // Override auto-detected project name
433
+ timeoutMs: 10000, // Request timeout
434
+ retries: 3 // Retry count on failure
435
+ },
436
+
437
+ // Hierarchical context injection configuration
438
+ contextInjection: {
439
+ enabled: true, // Enable hierarchical injection
440
+ sections: {
441
+ // High priority - always inject
442
+ projectContext: { enabled: true, priority: 'high' },
443
+ keyDecisions: { enabled: true, priority: 'high', maxItems: 10 },
444
+ currentState: { enabled: true, priority: 'high', maxItems: 10 },
445
+ remembered: { enabled: true, priority: 'high' },
446
+ // Medium priority - inject if relevant/recent
447
+ recentWork: { enabled: true, priority: 'medium', maxItems: 5, maxAgeDays: 7 },
448
+ gitChanges: { enabled: true, priority: 'medium' },
449
+ activeEntities: { enabled: true, priority: 'medium', maxFiles: 5, maxFunctions: 5 },
450
+ // Low priority - minimal injection
451
+ recentEntries: { enabled: true, priority: 'low', maxItems: 4 }
452
+ },
453
+ // When context budget is limited, drop low priority first
454
+ budgetMode: 'adaptive' // 'adaptive' | 'strict' | 'full'
455
+ }
456
+ };
457
+
458
+ let config = defaultConfig;
459
+ if (existsSync(CONFIG_FILE)) {
460
+ try {
461
+ config = deepMerge(defaultConfig, JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')));
462
+ } catch (e) {
463
+ logError(e, 'loadConfig:config.json');
464
+ }
465
+ }
466
+
467
+ // Backward compat: map legacy summarizeResponses boolean to responseSummarization
468
+ // Only apply if user explicitly set summarizeResponses in their config
469
+ if (existsSync(CONFIG_FILE)) {
470
+ try {
471
+ const userConfig = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
472
+ if (userConfig.summarizeResponses !== undefined && userConfig.responseSummarization === undefined) {
473
+ config.responseSummarization = userConfig.summarizeResponses ? 'extractive' : 'none';
474
+ }
475
+ } catch { /* already logged above */ }
476
+ }
477
+ delete config.summarizeResponses;
478
+
479
+ // Resolve claudePath to absolute path if it's a bare command name.
480
+ // The claude-agent-sdk requires an absolute path, not a PATH lookup.
481
+ if (config.claudePath && !config.claudePath.startsWith('/')) {
482
+ try {
483
+ const resolved = execFileSync('which', [config.claudePath], {
484
+ encoding: 'utf-8',
485
+ stdio: ['ignore', 'pipe', 'ignore']
486
+ }).trim();
487
+ if (resolved) {
488
+ config.claudePath = resolved;
489
+ }
490
+ } catch {
491
+ // 'which' failed — keep original value, will fail later with a clear error
492
+ }
493
+ }
494
+
495
+ _cachedConfig = config;
496
+ return config;
497
+ }
498
+
499
+ /**
500
+ * Strip low-information lead-in sentences from the start of text.
501
+ * e.g. "Here's a summary of what changed:" → removed
502
+ * "Let me explain the changes." → removed
503
+ * Only removes when there is substantive content afterwards.
504
+ */
505
+ export function stripLeadIns(text) {
506
+ if (!text) return text;
507
+ let result = text;
508
+
509
+ // Case 1: First line is a short lead-in ending with ':' (sets up a list)
510
+ const lines = result.split('\n');
511
+ const firstLine = lines[0]?.trim() || '';
512
+ if (firstLine.length < 80 && /:\s*$/.test(firstLine) && lines.length > 1) {
513
+ const rest = lines.slice(1).join('\n').trim();
514
+ if (rest) result = rest;
515
+ }
516
+
517
+ // Case 2: First sentence is meta-commentary ("Here's what I see.")
518
+ const sentenceEnd = result.match(/^(.+?[.!?])\s+(.+)/s);
519
+ if (sentenceEnd) {
520
+ const first = sentenceEnd[1].trim();
521
+ if (first.length < 80 && isLeadIn(first)) {
522
+ result = sentenceEnd[2].trim();
523
+ }
524
+ }
525
+
526
+ return result;
527
+ }
528
+
529
+ /**
530
+ * Strip markdown formatting, emoji, and decorative elements from text.
531
+ * Keeps the semantic content, removes rendering artifacts.
532
+ * Used on response/agent output before logging — the log's consumers
533
+ * (summarization, entity extraction, context injection) don't render markdown.
534
+ */
535
+ export function stripMarkdown(text) {
536
+ if (!text || typeof text !== 'string') return text;
537
+
538
+ let s = text;
539
+
540
+ // Code block fences (keep content, drop ```lang markers)
541
+ s = s.replace(/^```[^\n]*\n?/gm, '');
542
+
543
+ // HTML tags
544
+ s = s.replace(/<[^>]+>/g, '');
545
+
546
+ // Images ![alt](url) → remove
547
+ s = s.replace(/!\[([^\]]*)\]\([^)]+\)/g, '');
548
+
549
+ // Links [text](url) → text
550
+ s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
551
+
552
+ // Headers at line start
553
+ s = s.replace(/^#{1,6}\s+/gm, '');
554
+
555
+ // Bold **text** → text (before italic)
556
+ s = s.replace(/\*\*(.+?)\*\*/g, '$1');
557
+
558
+ // Italic *text* → text (after bold is gone, remaining paired * is italic)
559
+ s = s.replace(/\*([^*\n]+)\*/g, '$1');
560
+
561
+ // Strikethrough ~~text~~ → text
562
+ s = s.replace(/~~(.+?)~~/g, '$1');
563
+
564
+ // Inline backticks `code` → code
565
+ s = s.replace(/`([^`]+)`/g, '$1');
566
+
567
+ // Block quotes at line start
568
+ s = s.replace(/^>\s?/gm, '');
569
+
570
+ // Checkboxes (before bullet stripping)
571
+ s = s.replace(/^(\s*)[-*]\s*\[[ x]\]\s*/gm, '$1');
572
+
573
+ // Bullet/list markers at line start (- or * followed by space)
574
+ s = s.replace(/^(\s*)[-*]\s+/gm, '$1');
575
+
576
+ // Numbered list markers
577
+ s = s.replace(/^(\s*)\d+\.\s+/gm, '$1');
578
+
579
+ // Horizontal rules (line is only ---, ***, ___)
580
+ s = s.replace(/^[-*_]{3,}\s*$/gm, '');
581
+
582
+ // Emoji (presentation + pictographic + modifiers/ZWJ)
583
+ s = s.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}\u{200D}\u{FE0F}]+/gu, '');
584
+
585
+ // Collapse 3+ blank lines to one blank line
586
+ s = s.replace(/\n{3,}/g, '\n\n');
587
+
588
+ // Trim trailing whitespace per line and overall
589
+ s = s.split('\n').map(l => l.trimEnd()).join('\n').trim();
590
+
591
+ return s;
592
+ }
593
+
594
+ const LEAD_IN_RE = /^(?:here(?:'s| is| are)|let me|i'll |i will |i'm going to|now,? let me|so,? here|ok(?:ay)?,? (?:so|let|here|now))/i;
595
+
596
+ function isLeadIn(sentence) {
597
+ return LEAD_IN_RE.test(sentence);
598
+ }
599
+
600
+ /**
601
+ * Split text into logical units (sentences, paragraphs, bullet items)
602
+ * Handles markdown formatting, bullet lists, and paragraph breaks
603
+ */
604
+ export function splitSentences(text) {
605
+ const units = [];
606
+
607
+ const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim());
608
+
609
+ for (const para of paragraphs) {
610
+ const lines = para.split('\n').map(l => l.trim()).filter(l => l);
611
+ const isBulletList = lines.every(l => /^[-*•]\s/.test(l) || l === '');
612
+
613
+ if (isBulletList) {
614
+ for (const line of lines) {
615
+ const content = line.replace(/^[-*•]\s+/, '').trim();
616
+ if (content) units.push(content);
617
+ }
618
+ } else {
619
+ const normalized = para.replace(/\s+/g, ' ').trim();
620
+ const sentences = normalized.split(/(?<=[.!?])\s+(?=[A-Z])/).filter(s => s.trim());
621
+ if (sentences.length > 0) {
622
+ units.push(...sentences);
623
+ } else if (normalized) {
624
+ units.push(normalized);
625
+ }
626
+ }
627
+ }
628
+
629
+ if (units.length === 0 && text.trim()) {
630
+ units.push(text.replace(/\s+/g, ' ').trim());
631
+ }
632
+
633
+ return units;
634
+ }
635
+
636
+ /**
637
+ * Build a regex from a word list (cached).
638
+ */
639
+ const _wordRegexCache = new Map();
640
+ function getWordRegex(words) {
641
+ const key = words.join('|');
642
+ if (!_wordRegexCache.has(key)) {
643
+ const alternation = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
644
+ _wordRegexCache.set(key, new RegExp(`\\b(?:${alternation})\\b`, 'gi'));
645
+ }
646
+ return _wordRegexCache.get(key);
647
+ }
648
+
649
+ // Matches file paths (e.g. src/utils.mjs, ./config.json) and function-like refs (e.g. handleLogin())
650
+ const ENTITY_RE = /(?:[\w./\\-]+\.(?:js|ts|jsx|tsx|mjs|cjs|py|go|rs|java|json|yaml|yml|md|sh|toml))\b|\b\w+(?:\(\))/g;
651
+
652
+ /**
653
+ * Score a sentence for extractive summarization.
654
+ * Considers action words, reasoning words, and entity references.
655
+ */
656
+ function scoreSentence(sentence, config) {
657
+ let score = 0;
658
+
659
+ const actionWords = config.actionWords || [];
660
+ if (actionWords.length > 0) {
661
+ const regex = getWordRegex(actionWords);
662
+ regex.lastIndex = 0;
663
+ const matches = sentence.match(regex);
664
+ if (matches) score += matches.length;
665
+ }
666
+
667
+ const reasoningWords = config.reasoningWords || [];
668
+ if (reasoningWords.length > 0) {
669
+ const regex = getWordRegex(reasoningWords);
670
+ regex.lastIndex = 0;
671
+ const matches = sentence.match(regex);
672
+ if (matches) score += matches.length * 0.8;
673
+ }
674
+
675
+ // Sentences referencing files or functions get a boost
676
+ const entityMatches = sentence.match(ENTITY_RE);
677
+ if (entityMatches) score += entityMatches.length * 0.5;
678
+
679
+ return score;
680
+ }
681
+
682
+ /**
683
+ * Extractive summarization using action words, reasoning words, and entity references.
684
+ * Strips lead-ins, splits into sentences, scores by signal words,
685
+ * always keeps the first sentence, returns top N in original order.
686
+ */
687
+ export function extractiveSummarize(text, config) {
688
+ const cleaned = stripLeadIns(text);
689
+ const sentences = splitSentences(cleaned);
690
+
691
+ if (sentences.length === 0) return text;
692
+ if (sentences.length <= config.maxSummarySentences) return sentences.join(' ');
693
+
694
+ // Score all sentences
695
+ const scored = sentences.map((sentence, index) => ({
696
+ sentence,
697
+ index,
698
+ score: scoreSentence(sentence, config)
699
+ }));
700
+
701
+ // First sentence always included (usually the most informative)
702
+ const selected = new Set([0]);
703
+
704
+ // Sort remaining by score descending, then by position
705
+ const rest = scored.filter(s => s.index !== 0);
706
+ rest.sort((a, b) => {
707
+ if (b.score !== a.score) return b.score - a.score;
708
+ return a.index - b.index;
709
+ });
710
+
711
+ // Fill remaining slots
712
+ for (const s of rest) {
713
+ if (selected.size >= config.maxSummarySentences) break;
714
+ selected.add(s.index);
715
+ }
716
+
717
+ // Return in original order
718
+ return scored
719
+ .filter(s => selected.has(s.index))
720
+ .sort((a, b) => a.index - b.index)
721
+ .map(s => s.sentence)
722
+ .join(' ');
723
+ }
724
+
725
+ /**
726
+ * Format a structured log entry for display
727
+ * Used by session-start.mjs to render entries with localized timestamps
728
+ */
729
+ export function formatEntry(entry) {
730
+ const ts = localTime(entry.ts);
731
+ let text = `[${ts}] ${formatEntryBrief(entry)}`;
732
+
733
+ // If this entry was deduplicated and has merged context, show it
734
+ if (entry._mergedFrom && entry._mergedFrom.length > 0) {
735
+ text += ` (also: ${entry._mergedFrom.join(', ')})`;
736
+ }
737
+
738
+ return text;
739
+ }
740
+
741
+ function localTime(ts) {
742
+ try {
743
+ return new Date(ts).toLocaleString(undefined, {
744
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false
745
+ });
746
+ } catch {
747
+ return ts;
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Format an entry without timestamp (for use inside grouped summaries)
753
+ */
754
+ function formatEntryBrief(entry) {
755
+ const c = entry.content || '';
756
+ switch (entry.type) {
757
+ case 'prompt':
758
+ return `User: ${stripPrefix(c, 'User: ')}`;
759
+ case 'response':
760
+ return `Assistant: ${stripPrefix(c, 'Assistant: ')}`;
761
+ case 'agent': {
762
+ const text = stripPrefix(c, /^\[[\w-]+\]\s*/);
763
+ return `Agent (${entry.agent_type || 'unknown'}): ${text}`;
764
+ }
765
+ case 'task': {
766
+ // New format has action/subject/outcome, old format has content
767
+ if (entry.action) {
768
+ const outcome = entry.outcome && entry.outcome !== entry.action ? ` [${entry.outcome}]` : '';
769
+ return `Task ${entry.action}: ${entry.subject}${outcome}`;
770
+ }
771
+ return `Task: ${c}`;
772
+ }
773
+ case 'commit':
774
+ return `Commit: ${stripPrefix(c, 'Git commit: ')}`;
775
+ default:
776
+ return `(${entry.type}) ${c}`;
777
+ }
778
+ }
779
+
780
+ function stripPrefix(str, prefix) {
781
+ if (typeof prefix === 'string') {
782
+ return str.startsWith(prefix) ? str.slice(prefix.length) : str;
783
+ }
784
+ // regex
785
+ return str.replace(prefix, '');
786
+ }
787
+
788
+ /**
789
+ * Format JSONL lines grouped by local date for summarization prompts.
790
+ * Returns a string with date headers and bullet-listed entries.
791
+ */
792
+ export function formatEntriesForSummary(lines) {
793
+ const entries = lines.map(line => {
794
+ try { return JSON.parse(line); }
795
+ catch { return null; }
796
+ }).filter(Boolean);
797
+
798
+ if (entries.length === 0) return '';
799
+
800
+ // Group by local date
801
+ const groups = new Map();
802
+ for (const entry of entries) {
803
+ const dayKey = new Date(entry.ts).toLocaleDateString(undefined, {
804
+ weekday: 'short', year: 'numeric', month: 'short', day: 'numeric'
805
+ });
806
+ if (!groups.has(dayKey)) groups.set(dayKey, []);
807
+ groups.get(dayKey).push(entry);
808
+ }
809
+
810
+ const sections = [];
811
+ for (const [day, dayEntries] of groups) {
812
+ const items = dayEntries.map(e => `- ${formatEntryBrief(e)}`).join('\n');
813
+ sections.push(`### ${day}\n${items}`);
814
+ }
815
+
816
+ return sections.join('\n\n');
817
+ }
818
+
819
+ /**
820
+ * Default empty structured summary
821
+ */
822
+ export function emptyStructuredSummary() {
823
+ return {
824
+ projectContext: '',
825
+ keyDecisions: [],
826
+ currentState: [],
827
+ recentWork: [],
828
+ lastUpdated: null
829
+ };
830
+ }
831
+
832
+ /**
833
+ * Render structured summary JSON to markdown for session injection
834
+ * Supports hierarchical rendering with configurable sections
835
+ *
836
+ * @param {object} summary - Structured summary object
837
+ * @param {string} projectName - Project name
838
+ * @param {object} options - Rendering options from contextInjection config
839
+ * @returns {object} { high: string, medium: string } - Separated by priority
840
+ */
841
+ export function renderSummaryToMarkdown(summary, projectName, options = {}) {
842
+ const sections = options.sections || {};
843
+ const highLines = ['# Claude Memory Summary'];
844
+ const mediumLines = [];
845
+
846
+ if (summary.lastUpdated) {
847
+ const ts = new Date(summary.lastUpdated).toISOString().replace('T', ' ').split('.')[0] + ' UTC';
848
+ highLines.push(`\n*Last updated: ${ts}*`);
849
+ }
850
+
851
+ // Project Context - HIGH priority
852
+ const pcConfig = sections.projectContext || { enabled: true };
853
+ if (pcConfig.enabled !== false && summary.projectContext) {
854
+ highLines.push('\n## Project Context');
855
+ highLines.push(summary.projectContext);
856
+ }
857
+
858
+ // Key Decisions - HIGH priority
859
+ const kdConfig = sections.keyDecisions || { enabled: true, maxItems: 10 };
860
+ if (kdConfig.enabled !== false && summary.keyDecisions?.length > 0) {
861
+ const maxItems = kdConfig.maxItems || 10;
862
+ const decisions = summary.keyDecisions.slice(-maxItems); // Keep most recent
863
+ highLines.push('\n## Key Decisions');
864
+ for (const d of decisions) {
865
+ const reason = d.reason ? ` — ${d.reason}` : '';
866
+ highLines.push(`- **${d.decision}**${reason}`);
867
+ }
868
+ }
869
+
870
+ // Current State - HIGH priority
871
+ const csConfig = sections.currentState || { enabled: true, maxItems: 10 };
872
+ if (csConfig.enabled !== false && summary.currentState?.length > 0) {
873
+ const maxItems = csConfig.maxItems || 10;
874
+ const staleAfterDays = csConfig.staleAfterDays ?? 3;
875
+ const completedPattern = /\b(fixed|completed|implemented|done|resolved|removed|merged)\b/i;
876
+ const now = Date.now();
877
+
878
+ const states = summary.currentState
879
+ .filter(s => {
880
+ if (staleAfterDays === 0) return true; // Disabled
881
+ if (!completedPattern.test(s.status)) return true;
882
+ if (!s.updatedAt) return true; // Legacy data — keep
883
+ return (now - new Date(s.updatedAt).getTime()) < staleAfterDays * 86400000;
884
+ })
885
+ .slice(-maxItems);
886
+
887
+ if (states.length > 0) {
888
+ highLines.push('\n## Current State');
889
+ for (const s of states) {
890
+ highLines.push(`- **${s.topic}**: ${s.status}`);
891
+ }
892
+ }
893
+ }
894
+
895
+ // Recent Work - MEDIUM priority (filter by recency)
896
+ const rwConfig = sections.recentWork || { enabled: true, maxItems: 5, maxAgeDays: 7 };
897
+ if (rwConfig.enabled !== false && summary.recentWork?.length > 0) {
898
+ const maxItems = rwConfig.maxItems || 5;
899
+ const maxAgeDays = rwConfig.maxAgeDays || 7;
900
+ const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
901
+
902
+ // Filter by recency and limit
903
+ const recentWork = summary.recentWork
904
+ .filter(w => {
905
+ if (!w.date) return true; // Include undated items
906
+ const itemDate = new Date(w.date).getTime();
907
+ return itemDate >= cutoff;
908
+ })
909
+ .slice(-maxItems);
910
+
911
+ if (recentWork.length > 0) {
912
+ mediumLines.push('\n## Recent Work');
913
+ for (const w of recentWork) {
914
+ const date = w.date ? `[${w.date}] ` : '';
915
+ mediumLines.push(`- ${date}${w.summary}`);
916
+ }
917
+ }
918
+ }
919
+
920
+ // Return both priority levels separately for flexible injection
921
+ return {
922
+ high: highLines.join('\n'),
923
+ medium: mediumLines.join('\n'),
924
+ full: highLines.concat(mediumLines).join('\n')
925
+ };
926
+ }
927
+
928
+ /**
929
+ * Legacy wrapper for backward compatibility
930
+ */
931
+ function renderSummaryFull(summary, projectName) {
932
+ const result = renderSummaryToMarkdown(summary, projectName, {});
933
+ return result.full;
934
+ }
935
+
936
+ /**
937
+ * Extract file paths from entry content
938
+ */
939
+ export function extractFilePaths(entry, config = {}) {
940
+ const content = entry.content || entry.subject || '';
941
+ const paths = [];
942
+ const allowedExtensions = config.fileExtensions || [
943
+ 'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'cpp', 'c', 'h',
944
+ 'css', 'scss', 'sass', 'less', 'html', 'vue', 'svelte', 'json', 'yaml', 'yml', 'md',
945
+ 'sql', 'sh', 'bash', 'zsh', 'toml', 'xml', 'graphql', 'prisma'
946
+ ];
947
+ const minLength = config.minEntityLength || 2;
948
+
949
+ // Match common file path patterns - require path-like structure
950
+ const patterns = [
951
+ // Paths with directory separators
952
+ /(?:^|[\s"'`])([a-zA-Z0-9_\-.]+\/[a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,6})(?:[\s"'`:]|$)/g,
953
+ // Files explicitly mentioned after keywords
954
+ /(?:file|in|from|to|edit|read|write|created|updated|modified)\s+[`"']?([a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,6})[`"']?/gi,
955
+ // Backtick-wrapped files
956
+ /`([a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,6})`/g,
957
+ // Standalone filenames with common extensions (stricter - must have recognizable pattern)
958
+ /(?:^|[\s"'`])([a-zA-Z][a-zA-Z0-9_\-]*\.(?:ts|js|tsx|jsx|mjs|py|go|rs|java|vue|svelte|md|json|yaml|yml))(?:[\s"'`:]|$)/g,
959
+ ];
960
+
961
+ for (const pattern of patterns) {
962
+ pattern.lastIndex = 0;
963
+ let match;
964
+ while ((match = pattern.exec(content)) !== null) {
965
+ const path = match[1];
966
+ if (path && path.length >= minLength && path.length < 100 && !paths.includes(path)) {
967
+ // Must have a recognizable extension
968
+ const ext = path.split('.').pop()?.toLowerCase();
969
+ if (ext && allowedExtensions.includes(ext)) {
970
+ // Exclude common false positives
971
+ if (!isFileFalsePositive(path)) {
972
+ paths.push(path);
973
+ }
974
+ }
975
+ }
976
+ }
977
+ }
978
+
979
+ return paths;
980
+ }
981
+
982
+ /**
983
+ * Check if a matched path is a false positive
984
+ */
985
+ export function isFileFalsePositive(path) {
986
+ const lower = path.toLowerCase();
987
+ // Exclude version numbers (1.0.0.js), URLs (http://), etc.
988
+ if (/^\d+\.\d+/.test(path)) return true;
989
+ if (lower.startsWith('http') || lower.startsWith('www.')) return true;
990
+ // Exclude common words that match file patterns
991
+ const falsePositives = ['property', 'of.undefined', 'read.property'];
992
+ return falsePositives.some(fp => lower.includes(fp));
993
+ }
994
+
995
+ /**
996
+ * Extract function/method names from entry content
997
+ */
998
+ export function extractFunctionNames(entry, config = {}) {
999
+ const content = entry.content || entry.subject || '';
1000
+ const functions = [];
1001
+ const minLength = config.minEntityLength || 3; // Functions should be at least 3 chars
1002
+
1003
+ // Match function patterns - be more conservative
1004
+ const patterns = [
1005
+ // Function declarations with clear syntax
1006
+ /(?:function|const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]{2,})\s*(?:=\s*(?:async\s*)?\(|=\s*(?:async\s*)?function|\()/g,
1007
+ // Backtick-wrapped function calls (common in docs/messages)
1008
+ /`([a-zA-Z_$][a-zA-Z0-9_$]{2,})\s*\(`/g,
1009
+ /`([a-zA-Z_$][a-zA-Z0-9_$]{2,})\(\)`/g,
1010
+ // "the handleX function", "method handleX" - requires camelCase or snake_case
1011
+ /(?:function|method|handler|callback)\s+[`"']?([a-zA-Z_$][a-zA-Z0-9_$]*[A-Z_][a-zA-Z0-9_$]*)[`"']?/gi,
1012
+ // Python: def name( - must have decent length
1013
+ /def\s+([a-zA-Z_][a-zA-Z0-9_]{2,})\s*\(/g,
1014
+ ];
1015
+
1016
+ // Common false positives to exclude (expand the list)
1017
+ const exclude = new Set([
1018
+ // Keywords
1019
+ 'if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'break', 'continue',
1020
+ 'new', 'typeof', 'instanceof', 'delete', 'void', 'throw', 'try', 'finally',
1021
+ 'import', 'export', 'from', 'require', 'module', 'default', 'case',
1022
+ 'class', 'extends', 'constructor', 'super', 'this', 'self', 'static',
1023
+ 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
1024
+ 'async', 'await', 'yield', 'let', 'const', 'var', 'function',
1025
+ // Built-in objects
1026
+ 'console', 'window', 'document', 'process', 'global', 'module', 'exports',
1027
+ 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date', 'Math', 'JSON',
1028
+ 'Promise', 'Error', 'Map', 'Set', 'RegExp', 'Function', 'Symbol', 'BigInt',
1029
+ 'Buffer', 'Uint8Array', 'ArrayBuffer', 'DataView', 'Proxy', 'Reflect',
1030
+ // Common methods (too generic)
1031
+ 'get', 'set', 'has', 'add', 'delete', 'clear', 'keys', 'values', 'entries',
1032
+ 'then', 'catch', 'finally', 'resolve', 'reject', 'all', 'race', 'any',
1033
+ 'log', 'error', 'warn', 'info', 'debug', 'trace', 'assert', 'dir', 'table',
1034
+ 'push', 'pop', 'shift', 'unshift', 'slice', 'splice', 'concat', 'join', 'flat',
1035
+ 'map', 'filter', 'reduce', 'forEach', 'find', 'some', 'every', 'includes', 'sort',
1036
+ 'length', 'size', 'indexOf', 'lastIndexOf', 'replace', 'split', 'trim', 'match',
1037
+ 'toString', 'valueOf', 'toJSON', 'toLocaleString', 'parse', 'stringify',
1038
+ 'read', 'write', 'open', 'close', 'send', 'receive', 'emit', 'on', 'off',
1039
+ 'start', 'stop', 'run', 'exec', 'call', 'apply', 'bind', 'create', 'destroy',
1040
+ 'init', 'setup', 'cleanup', 'reset', 'update', 'render', 'mount', 'unmount',
1041
+ // Common short words that aren't useful
1042
+ 'was', 'the', 'not', 'and', 'for', 'are', 'but', 'can', 'has', 'had', 'did',
1043
+ 'use', 'will', 'would', 'could', 'should', 'may', 'might', 'must',
1044
+ ]);
1045
+
1046
+ for (const pattern of patterns) {
1047
+ pattern.lastIndex = 0;
1048
+ let match;
1049
+ while ((match = pattern.exec(content)) !== null) {
1050
+ const name = match[1];
1051
+ if (name && name.length >= minLength && name.length < 50 &&
1052
+ !functions.includes(name) && !exclude.has(name) && !exclude.has(name.toLowerCase())) {
1053
+ // Additional check: should look like a real function name (has mixed case or underscore)
1054
+ if (/[A-Z]/.test(name) || name.includes('_') || name.length >= 6) {
1055
+ functions.push(name);
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ return functions;
1062
+ }
1063
+
1064
+ /**
1065
+ * Extract error messages from entry content
1066
+ */
1067
+ export function extractErrorMessages(entry, config = {}) {
1068
+ const content = entry.content || entry.subject || '';
1069
+ const errors = [];
1070
+ const minLength = config.minEntityLength || 2;
1071
+
1072
+ // Match error patterns
1073
+ const patterns = [
1074
+ // Standard error types
1075
+ /\b((?:Type|Reference|Syntax|Range|URI|Eval|Internal|Aggregate)?Error):\s*([^\n.]{5,100})/g,
1076
+ // Exception patterns
1077
+ /\b(Exception|Fault):\s*([^\n.]{5,100})/gi,
1078
+ // "error:" prefix
1079
+ /\berror:\s*([^\n.]{5,80})/gi,
1080
+ // "failed:" or "failure:"
1081
+ /\b(?:failed|failure):\s*([^\n.]{5,80})/gi,
1082
+ // Stack trace first line
1083
+ /^\s*at\s+([^\n]{10,100})/gm,
1084
+ ];
1085
+
1086
+ for (const pattern of patterns) {
1087
+ pattern.lastIndex = 0;
1088
+ let match;
1089
+ while ((match = pattern.exec(content)) !== null) {
1090
+ // Combine error type and message if both captured
1091
+ const errorMsg = match[2] ? `${match[1]}: ${match[2]}` : match[1];
1092
+ const cleaned = errorMsg.trim().slice(0, 100); // Cap at 100 chars
1093
+ if (cleaned.length >= minLength && !errors.includes(cleaned)) {
1094
+ errors.push(cleaned);
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ return errors;
1100
+ }
1101
+
1102
+ /**
1103
+ * Extract package/module names from entry content
1104
+ */
1105
+ export function extractPackageNames(entry, config = {}) {
1106
+ const content = entry.content || entry.subject || '';
1107
+ const packages = [];
1108
+ const minLength = config.minEntityLength || 2;
1109
+
1110
+ // First, extract multi-package install commands
1111
+ const installMatch = content.match(/(?:npm|yarn|pnpm)\s+(?:install|add|i)\s+([^\n]+)/gi);
1112
+ if (installMatch) {
1113
+ for (const cmd of installMatch) {
1114
+ // Extract all packages from the command (space-separated after the verb)
1115
+ const pkgPart = cmd.replace(/^(?:npm|yarn|pnpm)\s+(?:install|add|i)\s+/i, '');
1116
+ const pkgNames = pkgPart.split(/\s+/).filter(p => p && !p.startsWith('-'));
1117
+ for (let name of pkgNames) {
1118
+ // Strip version specifier but keep scope
1119
+ name = name.replace(/@[\d^~>=<.*]+$/, '');
1120
+ if (isValidPackageName(name, minLength)) {
1121
+ if (!packages.includes(name)) packages.push(name);
1122
+ }
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ // Additional patterns for imports/requires
1128
+ const patterns = [
1129
+ // import from '@scope/package' or 'package'
1130
+ /(?:import|from)\s+['"](@[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)['"]/g,
1131
+ /(?:import|from)\s+['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]/g,
1132
+ // require('@scope/package') or require('package')
1133
+ /require\s*\(\s*['"](@[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)['"]\s*\)/g,
1134
+ /require\s*\(\s*['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]\s*\)/g,
1135
+ // pip install
1136
+ /pip\s+install\s+([a-zA-Z][a-zA-Z0-9_-]*)/g,
1137
+ // Scoped package names in backticks
1138
+ /`(@[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)`/g,
1139
+ ];
1140
+
1141
+ for (const pattern of patterns) {
1142
+ pattern.lastIndex = 0;
1143
+ let match;
1144
+ while ((match = pattern.exec(content)) !== null) {
1145
+ let name = match[1];
1146
+ // Strip version specifier
1147
+ name = name.replace(/@[\d^~>=<.*]+$/, '');
1148
+ if (isValidPackageName(name, minLength) && !packages.includes(name)) {
1149
+ packages.push(name);
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ return packages;
1155
+ }
1156
+
1157
+ /**
1158
+ * Check if a string is a valid package name
1159
+ */
1160
+ export function isValidPackageName(name, minLength = 2) {
1161
+ if (!name || name.length < minLength || name.length >= 60) return false;
1162
+ if (name.startsWith('./') || name.startsWith('../') || name.startsWith('/')) return false;
1163
+
1164
+ // Node.js built-in modules to exclude
1165
+ const exclude = new Set([
1166
+ 'fs', 'path', 'os', 'util', 'http', 'https', 'net', 'url', 'crypto',
1167
+ 'stream', 'events', 'buffer', 'child_process', 'cluster', 'dgram',
1168
+ 'dns', 'domain', 'readline', 'repl', 'tls', 'tty', 'v8', 'vm', 'zlib',
1169
+ 'assert', 'async_hooks', 'console', 'constants', 'perf_hooks', 'process',
1170
+ 'querystring', 'string_decoder', 'timers', 'worker_threads', 'inspector',
1171
+ 'module', 'punycode', 'sys', 'wasi',
1172
+ // Common relative imports that slip through
1173
+ 'src', 'lib', 'dist', 'build', 'test', 'tests', 'spec', 'utils', 'helpers',
1174
+ 'components', 'pages', 'hooks', 'services', 'models', 'types', 'interfaces',
1175
+ ]);
1176
+
1177
+ return !exclude.has(name);
1178
+ }
1179
+
1180
+ /**
1181
+ * Extract all entities from an entry
1182
+ * @param {object} entry - Log entry
1183
+ * @param {object} config - Entity extraction config
1184
+ * @returns {object} Extracted entities by category
1185
+ */
1186
+ export function extractEntitiesFromEntry(entry, config = {}) {
1187
+ const categories = config.categories || { files: true, functions: true, errors: true, packages: true };
1188
+ const result = {};
1189
+
1190
+ if (categories.files !== false) {
1191
+ const files = extractFilePaths(entry, config);
1192
+ if (files.length > 0) result.files = files;
1193
+ }
1194
+
1195
+ if (categories.functions !== false) {
1196
+ const functions = extractFunctionNames(entry, config);
1197
+ if (functions.length > 0) result.functions = functions;
1198
+ }
1199
+
1200
+ if (categories.errors !== false) {
1201
+ const errors = extractErrorMessages(entry, config);
1202
+ if (errors.length > 0) result.errors = errors;
1203
+ }
1204
+
1205
+ if (categories.packages !== false) {
1206
+ const packages = extractPackageNames(entry, config);
1207
+ if (packages.length > 0) result.packages = packages;
1208
+ }
1209
+
1210
+ return result;
1211
+ }
1212
+
1213
+ /**
1214
+ * Load entity index from file
1215
+ */
1216
+ export function loadEntityIndex(cwd = process.cwd()) {
1217
+ const paths = ensureMemoryDirs(cwd);
1218
+ if (existsSync(paths.entities)) {
1219
+ try {
1220
+ return JSON.parse(readFileSync(paths.entities, 'utf-8'));
1221
+ } catch (e) {
1222
+ logError(e, 'loadEntityIndex:entities.json');
1223
+ return emptyEntityIndex();
1224
+ }
1225
+ }
1226
+ return emptyEntityIndex();
1227
+ }
1228
+
1229
+ /**
1230
+ * Empty entity index structure
1231
+ */
1232
+ export function emptyEntityIndex() {
1233
+ return {
1234
+ files: {},
1235
+ functions: {},
1236
+ errors: {},
1237
+ packages: {},
1238
+ lastUpdated: null
1239
+ };
1240
+ }
1241
+
1242
+ /**
1243
+ * Update entity index with entities from an entry
1244
+ * @param {object} entry - Log entry
1245
+ * @param {string} cwd - Working directory
1246
+ * @param {object} config - Full config
1247
+ */
1248
+ function updateEntityIndex(entry, cwd = process.cwd(), config = {}) {
1249
+ const eeConfig = config.entityExtraction || {};
1250
+ if (eeConfig.enabled === false) return;
1251
+
1252
+ const entities = extractEntitiesFromEntry(entry, eeConfig);
1253
+ if (Object.keys(entities).length === 0) return;
1254
+
1255
+ const paths = ensureMemoryDirs(cwd);
1256
+ const lockPath = paths.entities + '.lock';
1257
+
1258
+ // Lock the entire read-modify-write cycle to prevent concurrent updates
1259
+ // from overwriting each other. If lock is contended, skip — entity data
1260
+ // is reconstructable and losing one update is acceptable.
1261
+ withFileLock(lockPath, () => {
1262
+ const index = loadEntityIndex(cwd);
1263
+ const maxContexts = eeConfig.maxContextsPerEntity || 5;
1264
+
1265
+ // Create context summary for this entry
1266
+ const contextSummary = {
1267
+ ts: entry.ts,
1268
+ type: entry.type,
1269
+ summary: truncateContext(entry.content || entry.subject || '', 80)
1270
+ };
1271
+
1272
+ // Update each category
1273
+ for (const [category, names] of Object.entries(entities)) {
1274
+ if (!index[category]) index[category] = {};
1275
+
1276
+ for (const name of names) {
1277
+ if (!index[category][name]) {
1278
+ index[category][name] = {
1279
+ mentions: 0,
1280
+ lastSeen: null,
1281
+ contexts: []
1282
+ };
1283
+ }
1284
+
1285
+ const entityData = index[category][name];
1286
+ entityData.mentions++;
1287
+ entityData.lastSeen = entry.ts;
1288
+
1289
+ // Add context, keeping only the most recent N
1290
+ entityData.contexts.push(contextSummary);
1291
+ if (entityData.contexts.length > maxContexts) {
1292
+ entityData.contexts = entityData.contexts.slice(-maxContexts);
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ index.lastUpdated = new Date().toISOString();
1298
+
1299
+ // Prune stale entities (at most once per day)
1300
+ pruneEntityIndex(index, eeConfig);
1301
+
1302
+ // Write updated index
1303
+ try {
1304
+ writeFileSync(paths.entities, JSON.stringify(index, null, 2));
1305
+ } catch (e) {
1306
+ logError(e, 'updateEntityIndex:write');
1307
+ }
1308
+ });
1309
+ }
1310
+
1311
+ /**
1312
+ * Prune stale entities from the index.
1313
+ * Removes entities whose lastSeen is older than maxAgeDays.
1314
+ * Runs at most once per day (checks index.lastPruned).
1315
+ * Mutates the index object in place.
1316
+ */
1317
+ export function pruneEntityIndex(index, eeConfig = {}) {
1318
+ const maxAgeDays = eeConfig.maxAgeDays ?? 30;
1319
+ if (maxAgeDays <= 0) return; // Pruning disabled
1320
+
1321
+ // Only prune once per day
1322
+ if (index.lastPruned) {
1323
+ const sincePrune = Date.now() - new Date(index.lastPruned).getTime();
1324
+ if (sincePrune < 24 * 60 * 60 * 1000) return;
1325
+ }
1326
+
1327
+ const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
1328
+
1329
+ for (const category of ['files', 'functions', 'errors', 'packages']) {
1330
+ if (!index[category]) continue;
1331
+ for (const [name, data] of Object.entries(index[category])) {
1332
+ if (!data.lastSeen || new Date(data.lastSeen).getTime() < cutoff) {
1333
+ delete index[category][name];
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ index.lastPruned = new Date().toISOString();
1339
+ }
1340
+
1341
+ /**
1342
+ * Truncate text for context summaries
1343
+ */
1344
+ function truncateContext(text, maxLen) {
1345
+ if (!text) return '';
1346
+ const cleaned = text.replace(/\s+/g, ' ').trim();
1347
+ if (cleaned.length <= maxLen) return cleaned;
1348
+ return cleaned.slice(0, maxLen - 3) + '...';
1349
+ }
1350
+
1351
+ const LABEL_STOPWORDS = new Set([
1352
+ // Function words
1353
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
1354
+ 'of', 'with', 'by', 'from', 'is', 'was', 'are', 'were', 'be', 'been',
1355
+ 'has', 'had', 'have', 'do', 'does', 'did', 'will', 'would', 'could',
1356
+ 'should', 'may', 'might', 'can', 'shall', 'this', 'that', 'these',
1357
+ 'those', 'it', 'its', 'not', 'no', 'all', 'each', 'also', 'just',
1358
+ 'than', 'too', 'very', 'now', 'then', 'here', 'there', 'when', 'where',
1359
+ 'how', 'what', 'which', 'who', 'so', 'if', 'up', 'out', 'about', 'into',
1360
+ 'only', 'more', 'most', 'some', 'such', 'after', 'before', 'both',
1361
+ // Common verbs (noise in code summaries)
1362
+ 'add', 'added', 'fix', 'fixed', 'update', 'updated', 'use', 'used', 'using',
1363
+ 'show', 'make', 'set', 'get', 'run', 'check', 'create', 'new', 'wire',
1364
+ 'last', 'first', 'next', 'let', 'see', 'need', 'keep', 'try', 'free', 'data',
1365
+ 'file', 'files', 'line', 'lines', 'code', 'mjs', 'json', 'already', 'instead',
1366
+ 'three', 'four', 'five', 'two', 'one', 'per', 'each', 'default', 'none'
1367
+ ]);
1368
+
1369
+ function deriveClusterLabel(members, sharedTimestamps) {
1370
+ const tsSet = new Set(sharedTimestamps);
1371
+ const summaries = [];
1372
+ for (const m of members) {
1373
+ for (const ctx of m.data.contexts) {
1374
+ if (tsSet.has(ctx.ts) && ctx.summary) summaries.push(ctx.summary);
1375
+ }
1376
+ }
1377
+
1378
+ // Build set of entity name stems to exclude from labels (avoids "utils" label for utils.mjs cluster)
1379
+ const entityNames = new Set(
1380
+ members.map(m => m.name.replace(/\.[^.]+$/, '').toLowerCase())
1381
+ );
1382
+
1383
+ const wordCounts = {};
1384
+ for (const summary of summaries) {
1385
+ const words = summary
1386
+ .replace(/\*\*/g, '')
1387
+ .replace(/`/g, ' ')
1388
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
1389
+ .toLowerCase()
1390
+ .replace(/[^a-z\s]/g, ' ')
1391
+ .split(/\s+/)
1392
+ .filter(w => w.length > 2 && !LABEL_STOPWORDS.has(w) && !entityNames.has(w));
1393
+ const seen = new Set();
1394
+ for (const word of words) {
1395
+ if (!seen.has(word)) { wordCounts[word] = (wordCounts[word] || 0) + 1; seen.add(word); }
1396
+ }
1397
+ }
1398
+
1399
+ const topWords = Object.entries(wordCounts)
1400
+ .sort((a, b) => b[1] - a[1])
1401
+ .slice(0, 2)
1402
+ .map(([w]) => w);
1403
+ return topWords.length > 0 ? topWords.join(' ') : null;
1404
+ }
1405
+
1406
+ function findCoOccurrenceClusters(allEntities) {
1407
+ if (allEntities.length < 2) return [];
1408
+
1409
+ // Map each timestamp to the entity indices that share it
1410
+ const tsToIndices = new Map();
1411
+ allEntities.forEach((e, idx) => {
1412
+ for (const ctx of e.data.contexts) {
1413
+ if (!ctx.ts) continue;
1414
+ if (!tsToIndices.has(ctx.ts)) tsToIndices.set(ctx.ts, []);
1415
+ tsToIndices.get(ctx.ts).push(idx);
1416
+ }
1417
+ });
1418
+
1419
+ // Count co-occurrences per pair
1420
+ const pairCounts = new Map();
1421
+ for (const [ts, indices] of tsToIndices) {
1422
+ if (indices.length < 2) continue;
1423
+ for (let a = 0; a < indices.length; a++) {
1424
+ for (let b = a + 1; b < indices.length; b++) {
1425
+ const key = `${indices[a]}:${indices[b]}`;
1426
+ if (!pairCounts.has(key)) pairCounts.set(key, { count: 0, sharedTs: [] });
1427
+ const pair = pairCounts.get(key);
1428
+ pair.count++;
1429
+ pair.sharedTs.push(ts);
1430
+ }
1431
+ }
1432
+ }
1433
+
1434
+ // Greedy clustering: pairs with >= 2 shared timestamps
1435
+ const significantPairs = [...pairCounts.entries()]
1436
+ .filter(([, v]) => v.count >= 2)
1437
+ .sort((a, b) => b[1].count - a[1].count);
1438
+
1439
+ const entityCluster = new Map();
1440
+ const clusters = [];
1441
+
1442
+ for (const [pairKey, { sharedTs }] of significantPairs) {
1443
+ const [iStr, jStr] = pairKey.split(':');
1444
+ const i = parseInt(iStr), j = parseInt(jStr);
1445
+ const ci = entityCluster.get(i), cj = entityCluster.get(j);
1446
+
1447
+ if (ci !== undefined && cj !== undefined) continue;
1448
+ if (ci !== undefined && clusters[ci].members.length < 5) {
1449
+ clusters[ci].members.push(allEntities[j]);
1450
+ clusters[ci].sharedTs = [...new Set([...clusters[ci].sharedTs, ...sharedTs])];
1451
+ entityCluster.set(j, ci);
1452
+ } else if (cj !== undefined && clusters[cj].members.length < 5) {
1453
+ clusters[cj].members.push(allEntities[i]);
1454
+ clusters[cj].sharedTs = [...new Set([...clusters[cj].sharedTs, ...sharedTs])];
1455
+ entityCluster.set(i, cj);
1456
+ } else if (ci === undefined && cj === undefined) {
1457
+ const idx = clusters.length;
1458
+ clusters.push({ members: [allEntities[i], allEntities[j]], sharedTs });
1459
+ entityCluster.set(i, idx);
1460
+ entityCluster.set(j, idx);
1461
+ }
1462
+ }
1463
+
1464
+ return clusters
1465
+ .filter(c => c.members.length >= 2)
1466
+ .map(c => ({ label: deriveClusterLabel(c.members, c.sharedTs), members: c.members }));
1467
+ }
1468
+
1469
+ /**
1470
+ * Get entity mentions relevant to current context
1471
+ * @param {string} cwd - Working directory
1472
+ * @param {Array} recentFiles - Recently accessed files (optional)
1473
+ * @returns {object} Relevant entity data
1474
+ */
1475
+ export function getRelevantEntities(cwd = process.cwd(), recentFiles = []) {
1476
+ const index = loadEntityIndex(cwd);
1477
+ const result = { files: [], functions: [], errors: [], packages: [], clusters: [] };
1478
+ const now = Date.now();
1479
+ const DAY = 86400000;
1480
+
1481
+ // Phase 1: Score and select top entities per category
1482
+ const selectedByCategory = {};
1483
+ for (const category of ['files', 'functions', 'errors', 'packages']) {
1484
+ const entities = index[category];
1485
+ if (!entities || typeof entities !== 'object') continue;
1486
+
1487
+ const scored = [];
1488
+ for (const [name, data] of Object.entries(entities)) {
1489
+ const recency = data.lastSeen ? calculateRecencyScore(data.lastSeen, 24) : 0;
1490
+ const frequency = Math.min(data.mentions / 10, 1);
1491
+ const score = 0.6 * recency + 0.4 * frequency;
1492
+ const nameBoost = recentFiles.some(f => f.includes(name)) ? 0.3 : 0;
1493
+ scored.push({ name, data, score: score + nameBoost, category });
1494
+ }
1495
+ scored.sort((a, b) => b.score - a.score);
1496
+ selectedByCategory[category] = scored.slice(0, 10);
1497
+ }
1498
+
1499
+ // Phase 2: Cluster co-occurring entities
1500
+ const allSelected = Object.values(selectedByCategory).flat();
1501
+ const clusters = findCoOccurrenceClusters(allSelected);
1502
+ const clusteredKeys = new Set(
1503
+ clusters.flatMap(c => c.members.map(m => `${m.category}:${m.name}`))
1504
+ );
1505
+
1506
+ // Format a scored entity into output shape
1507
+ const formatEntity = (s) => {
1508
+ const recent24h = s.data.contexts.filter(c => c.ts && (now - new Date(c.ts).getTime()) < DAY).length;
1509
+ const recent7d = s.data.contexts.filter(c => c.ts && (now - new Date(c.ts).getTime()) < 7 * DAY).length;
1510
+ let velocity;
1511
+ if (recent24h > 0) velocity = `${recent24h}x today`;
1512
+ else if (recent7d > 0) velocity = `${recent7d}x this week`;
1513
+ else {
1514
+ const daysAgo = s.data.lastSeen ? Math.floor((now - new Date(s.data.lastSeen).getTime()) / DAY) : null;
1515
+ velocity = daysAgo !== null ? `${daysAgo}d ago` : null;
1516
+ }
1517
+ return {
1518
+ name: s.name, category: s.category,
1519
+ mentions: s.data.mentions, lastSeen: s.data.lastSeen,
1520
+ recentContext: s.data.contexts[s.data.contexts.length - 1]?.summary,
1521
+ contextTypes: [...new Set(s.data.contexts.map(c => c.type).filter(Boolean))],
1522
+ velocity
1523
+ };
1524
+ };
1525
+
1526
+ // Phase 3: Build output
1527
+ result.clusters = clusters.map(c => ({
1528
+ label: c.label,
1529
+ entities: c.members.map(formatEntity)
1530
+ }));
1531
+
1532
+ for (const category of ['files', 'functions', 'errors', 'packages']) {
1533
+ result[category] = (selectedByCategory[category] || [])
1534
+ .filter(s => !clusteredKeys.has(`${category}:${s.name}`))
1535
+ .map(formatEntity);
1536
+ }
1537
+
1538
+ return result;
1539
+ }
1540
+
1541
+ /**
1542
+ * Calculate recency score with exponential decay
1543
+ * @param {string} timestamp - ISO timestamp of entry
1544
+ * @param {number} halfLifeHours - Hours until score drops to 50%
1545
+ * @returns {number} Score between 0 and 1
1546
+ */
1547
+ export function calculateRecencyScore(timestamp, halfLifeHours = 24) {
1548
+ const ageMs = Date.now() - new Date(timestamp).getTime();
1549
+ const ageHours = ageMs / (1000 * 60 * 60);
1550
+ // Exponential decay: score = 0.5^(age/halfLife)
1551
+ return Math.pow(0.5, ageHours / halfLifeHours);
1552
+ }
1553
+
1554
+ /**
1555
+ * Calculate file relevance score based on path matching
1556
+ * @param {object} entry - Log entry
1557
+ * @param {string} cwd - Current working directory
1558
+ * @returns {number} Score between 0 and 1
1559
+ */
1560
+ export function calculateFileRelevanceScore(entry, cwd) {
1561
+ const filePaths = extractFilePaths(entry);
1562
+ if (filePaths.length === 0) return 0.5; // Neutral score for entries without file paths
1563
+
1564
+ let relevantCount = 0;
1565
+ const cwdParts = cwd.split('/').filter(Boolean);
1566
+ const projectName = cwdParts[cwdParts.length - 1] || '';
1567
+
1568
+ for (const filePath of filePaths) {
1569
+ // Check if file path is relative (likely in current project)
1570
+ if (!filePath.startsWith('/') && !filePath.startsWith('~')) {
1571
+ relevantCount++;
1572
+ continue;
1573
+ }
1574
+
1575
+ // Check if absolute path contains project name or cwd
1576
+ if (filePath.includes(projectName) || filePath.includes(cwd)) {
1577
+ relevantCount++;
1578
+ continue;
1579
+ }
1580
+
1581
+ // Check for common project directories
1582
+ if (filePath.match(/^(src|lib|test|scripts|plugin|components|pages)\//)) {
1583
+ relevantCount++;
1584
+ }
1585
+ }
1586
+
1587
+ return filePaths.length > 0 ? relevantCount / filePaths.length : 0.5;
1588
+ }
1589
+
1590
+ /**
1591
+ * Calculate entry type priority score
1592
+ * Considers both entry type and task outcome if applicable
1593
+ * @param {object} entry - Log entry
1594
+ * @param {object} typePriorities - Priority map by entry type
1595
+ * @param {object} outcomePriority - Priority map for task outcomes
1596
+ * @returns {number} Score between 0 and 1
1597
+ */
1598
+ export function calculateTypePriorityScore(entry, typePriorities, outcomePriority = null) {
1599
+ const type = entry.type || 'unknown';
1600
+ let baseScore = typePriorities[type] ?? 0.5;
1601
+
1602
+ // Apply outcome modifier for task entries
1603
+ if (type === 'task' && entry.outcome && outcomePriority) {
1604
+ const outcomeMultiplier = outcomePriority[entry.outcome] ?? 1.0;
1605
+ baseScore *= outcomeMultiplier;
1606
+ }
1607
+
1608
+ return baseScore;
1609
+ }
1610
+
1611
+ /**
1612
+ * Deduplicate log entries by grouping temporally close entries and keeping highest-signal
1613
+ *
1614
+ * When you ask Claude to do something, multiple entries are created:
1615
+ * - prompt: Your request
1616
+ * - task: The work item created
1617
+ * - response: What Claude did
1618
+ * - commit: The final commit (if any)
1619
+ *
1620
+ * These are all about the same work. This function groups them and keeps the highest-signal entry.
1621
+ *
1622
+ * @param {Array} entries - Parsed log entries (should be sorted by timestamp)
1623
+ * @param {object} config - Full config object
1624
+ * @returns {Array} Deduplicated entries
1625
+ */
1626
+ export function deduplicateEntries(entries, config = {}) {
1627
+ const dedupConfig = config.deduplication || {};
1628
+
1629
+ if (dedupConfig.enabled === false || entries.length <= 1) {
1630
+ return entries;
1631
+ }
1632
+
1633
+ const timeWindowMs = (dedupConfig.timeWindowMinutes || 5) * 60 * 1000;
1634
+ const typePriority = dedupConfig.typePriority || {
1635
+ commit: 100,
1636
+ task: 80,
1637
+ agent: 70,
1638
+ prompt: 40,
1639
+ response: 30,
1640
+ compact: 20
1641
+ };
1642
+ const mergeContext = dedupConfig.mergeContext !== false;
1643
+
1644
+ // Sort by timestamp (oldest first)
1645
+ const sorted = [...entries].sort((a, b) =>
1646
+ new Date(a.ts).getTime() - new Date(b.ts).getTime()
1647
+ );
1648
+
1649
+ // Group entries by time proximity
1650
+ const groups = [];
1651
+ let currentGroup = [sorted[0]];
1652
+
1653
+ for (let i = 1; i < sorted.length; i++) {
1654
+ const prev = sorted[i - 1];
1655
+ const curr = sorted[i];
1656
+ const timeDiff = new Date(curr.ts).getTime() - new Date(prev.ts).getTime();
1657
+
1658
+ if (timeDiff <= timeWindowMs) {
1659
+ // Within time window - add to current group
1660
+ currentGroup.push(curr);
1661
+ } else {
1662
+ // New group
1663
+ groups.push(currentGroup);
1664
+ currentGroup = [curr];
1665
+ }
1666
+ }
1667
+ groups.push(currentGroup); // Don't forget the last group
1668
+
1669
+ // Outcome priority for tasks (completed > in_progress > abandoned)
1670
+ const outcomePriority = { completed: 1.0, in_progress: 0.7, abandoned: 0.3 };
1671
+
1672
+ // For each group, keep the highest-signal entry
1673
+ const deduplicated = groups.map(group => {
1674
+ if (group.length === 1) {
1675
+ return group[0];
1676
+ }
1677
+
1678
+ // Sort by priority (highest first), considering outcome for tasks
1679
+ group.sort((a, b) => {
1680
+ let aPriority = typePriority[a.type] || 0;
1681
+ let bPriority = typePriority[b.type] || 0;
1682
+
1683
+ // Apply outcome modifier for tasks
1684
+ if (a.type === 'task' && a.outcome) {
1685
+ aPriority *= (outcomePriority[a.outcome] || 1.0);
1686
+ }
1687
+ if (b.type === 'task' && b.outcome) {
1688
+ bPriority *= (outcomePriority[b.outcome] || 1.0);
1689
+ }
1690
+
1691
+ return bPriority - aPriority;
1692
+ });
1693
+
1694
+ const winner = group[0];
1695
+
1696
+ // Optionally merge context from other entries
1697
+ if (mergeContext && group.length > 1) {
1698
+ const otherTypes = group.slice(1).map(e => e.type).filter((v, i, a) => a.indexOf(v) === i);
1699
+ if (otherTypes.length > 0) {
1700
+ // Add a note about what else was in this group
1701
+ winner._mergedFrom = otherTypes;
1702
+ }
1703
+ }
1704
+
1705
+ return winner;
1706
+ });
1707
+
1708
+ return deduplicated;
1709
+ }
1710
+
1711
+ /**
1712
+ * Calculate entity relevance score based on indexed entities
1713
+ * @param {object} entry - Log entry
1714
+ * @param {object} entityIndex - Entity index
1715
+ * @param {object} config - Entity extraction config
1716
+ * @returns {number} Score between 0 and 1
1717
+ */
1718
+ export function calculateEntityRelevanceScore(entry, entityIndex, config = {}) {
1719
+ if (!entityIndex || Object.keys(entityIndex).length === 0) return 0.5;
1720
+
1721
+ const entities = extractEntitiesFromEntry(entry, config);
1722
+ if (Object.keys(entities).length === 0) return 0.5;
1723
+
1724
+ let totalScore = 0;
1725
+ let entityCount = 0;
1726
+
1727
+ // Score based on how "hot" the entities mentioned are (recent + frequent = hot)
1728
+ for (const [category, names] of Object.entries(entities)) {
1729
+ if (!entityIndex[category]) continue;
1730
+
1731
+ for (const name of names) {
1732
+ const entityData = entityIndex[category][name];
1733
+ if (!entityData) continue;
1734
+
1735
+ entityCount++;
1736
+ const recency = entityData.lastSeen ? calculateRecencyScore(entityData.lastSeen, 24) : 0;
1737
+ const frequency = Math.min(entityData.mentions / 10, 1);
1738
+ totalScore += 0.6 * recency + 0.4 * frequency;
1739
+ }
1740
+ }
1741
+
1742
+ return entityCount > 0 ? totalScore / entityCount : 0.5;
1743
+ }
1744
+
1745
+ /**
1746
+ * Score and rank log entries by relevance
1747
+ * @param {Array} entries - Parsed log entries
1748
+ * @param {string} cwd - Current working directory
1749
+ * @param {object} config - Full config object
1750
+ * @returns {Array} Entries sorted by relevance score (highest first)
1751
+ */
1752
+ export function scoreEntriesByRelevance(entries, cwd, config) {
1753
+ const rsConfig = config.relevanceScoring || {};
1754
+
1755
+ if (rsConfig.enabled === false) {
1756
+ // If disabled, return entries in reverse chronological order
1757
+ return [...entries].reverse();
1758
+ }
1759
+
1760
+ const weights = rsConfig.weights || { recency: 0.4, fileRelevance: 0.35, typePriority: 0.25 };
1761
+ const typePriorities = rsConfig.typePriorities || {
1762
+ commit: 1.0,
1763
+ task: 0.9,
1764
+ agent: 0.8,
1765
+ prompt: 0.5,
1766
+ response: 0.3,
1767
+ compact: 0.4
1768
+ };
1769
+ const halfLifeHours = rsConfig.recencyHalfLifeHours || 24;
1770
+
1771
+ // Get outcome priority config for task scoring
1772
+ const otConfig = config.outcomeTracking || {};
1773
+ const outcomePriority = otConfig.enabled !== false ? (otConfig.outcomePriority || {
1774
+ completed: 1.0,
1775
+ in_progress: 0.7,
1776
+ abandoned: 0.3
1777
+ }) : null;
1778
+
1779
+ // Load entity index for entity-based scoring
1780
+ const eeConfig = config.entityExtraction || {};
1781
+ let entityIndex = null;
1782
+ if (eeConfig.enabled !== false && eeConfig.useInRelevanceScoring !== false) {
1783
+ entityIndex = loadEntityIndex(cwd);
1784
+ }
1785
+
1786
+ // Score each entry
1787
+ const scored = entries.map(entry => {
1788
+ const recencyScore = calculateRecencyScore(entry.ts, halfLifeHours);
1789
+ const fileScore = calculateFileRelevanceScore(entry, cwd);
1790
+ const typeScore = calculateTypePriorityScore(entry, typePriorities, outcomePriority);
1791
+
1792
+ // Calculate entity relevance if enabled
1793
+ let entityScore = 0;
1794
+ if (entityIndex) {
1795
+ entityScore = calculateEntityRelevanceScore(entry, entityIndex, eeConfig);
1796
+ }
1797
+
1798
+ // Weighted combination - adjust weights if entity scoring is active
1799
+ let totalScore;
1800
+ if (entityIndex && entityScore > 0.5) {
1801
+ // Blend in entity score, reducing other weights proportionally
1802
+ const entityWeight = 0.15;
1803
+ const scale = 1 - entityWeight;
1804
+ totalScore =
1805
+ scale * (weights.recency || 0) * recencyScore +
1806
+ scale * (weights.fileRelevance || 0) * fileScore +
1807
+ scale * (weights.typePriority || 0) * typeScore +
1808
+ entityWeight * entityScore;
1809
+ } else {
1810
+ totalScore =
1811
+ (weights.recency || 0) * recencyScore +
1812
+ (weights.fileRelevance || 0) * fileScore +
1813
+ (weights.typePriority || 0) * typeScore;
1814
+ }
1815
+
1816
+ return {
1817
+ entry,
1818
+ score: totalScore,
1819
+ breakdown: { recency: recencyScore, file: fileScore, type: typeScore, entity: entityScore }
1820
+ };
1821
+ });
1822
+
1823
+ // Sort by score descending
1824
+ scored.sort((a, b) => b.score - a.score);
1825
+
1826
+ return scored.map(s => s.entry);
1827
+ }
1828
+
1829
+ /**
1830
+ * Append a log entry using the buffered write system.
1831
+ * Writes to .pending.jsonl for batching, then flushes if throttle allows.
1832
+ * Also extracts and indexes entities from the entry.
1833
+ */
1834
+ export function appendLogEntry(entry, cwd = process.cwd()) {
1835
+ const paths = ensureMemoryDirs(cwd);
1836
+ const pendingPath = paths.log.replace('.jsonl', '.pending.jsonl');
1837
+ const config = loadConfig();
1838
+
1839
+ // Always write to pending file (fast, append-only)
1840
+ appendFileSync(pendingPath, JSON.stringify(entry) + '\n');
1841
+
1842
+ // Extract and index entities from the entry
1843
+ updateEntityIndex(entry, cwd, config);
1844
+
1845
+ // Invalidate cache since data has changed
1846
+ invalidateCache(cwd);
1847
+
1848
+ // Throttled flush - only flush every 5 seconds
1849
+ flushPendingLog(cwd, 5000);
1850
+ }
1851
+
1852
+ /**
1853
+ * Flush pending log entries to main log file.
1854
+ * Uses throttling to avoid excessive I/O.
1855
+ * @param {string} cwd - Working directory
1856
+ * @param {number} throttleMs - Minimum ms between flushes (0 = always flush)
1857
+ */
1858
+ export function flushPendingLog(cwd = process.cwd(), throttleMs = 0) {
1859
+ const paths = ensureMemoryDirs(cwd);
1860
+ const pendingPath = paths.log.replace('.jsonl', '.pending.jsonl');
1861
+ const flushingPath = pendingPath + '.flushing';
1862
+ const lastFlushPath = paths.log + '.lastflush';
1863
+
1864
+ // Check throttle
1865
+ if (throttleMs > 0 && existsSync(lastFlushPath)) {
1866
+ try {
1867
+ const lastFlush = parseInt(readFileSync(lastFlushPath, 'utf-8'), 10);
1868
+ if (Date.now() - lastFlush < throttleMs) {
1869
+ return; // Too soon, skip flush
1870
+ }
1871
+ } catch {
1872
+ // Ignore read errors
1873
+ }
1874
+ }
1875
+
1876
+ if (!existsSync(pendingPath)) {
1877
+ return;
1878
+ }
1879
+
1880
+ // Atomic rename: only one process wins; losers get ENOENT.
1881
+ // New entries written after this go to a fresh pending file.
1882
+ try {
1883
+ renameSync(pendingPath, flushingPath);
1884
+ } catch (e) {
1885
+ if (e.code === 'ENOENT') return; // Another process already claimed it
1886
+ logError(e, 'flushPendingLog:rename');
1887
+ return;
1888
+ }
1889
+
1890
+ try {
1891
+ const pending = readFileSync(flushingPath, 'utf-8').trim();
1892
+ if (pending) {
1893
+ const logWriteLock = paths.log + '.wlock';
1894
+ const lockResult = withFileLock(logWriteLock, () => {
1895
+ appendFileSync(paths.log, pending + '\n');
1896
+ return true;
1897
+ }, 30);
1898
+
1899
+ if (lockResult === undefined) {
1900
+ // Lock held by summarizer — restore flushing file so entries aren't lost
1901
+ try { renameSync(flushingPath, pendingPath); } catch {}
1902
+ return;
1903
+ }
1904
+ }
1905
+
1906
+ // Remove the flushing file now that entries are safely in the main log
1907
+ unlinkSync(flushingPath);
1908
+
1909
+ // Update last flush timestamp
1910
+ writeFileSync(lastFlushPath, Date.now().toString());
1911
+
1912
+ // Now check if summarization is needed (once, after batch)
1913
+ maybeSummarize(cwd);
1914
+ } catch (e) {
1915
+ // If append failed, restore pending file so entries aren't lost
1916
+ try {
1917
+ if (existsSync(flushingPath)) {
1918
+ renameSync(flushingPath, pendingPath);
1919
+ }
1920
+ } catch {}
1921
+ logError(e, 'flushPendingLog');
1922
+ }
1923
+ }
1924
+
1925
+ /**
1926
+ * Check if summarization is needed and spawn it in background if so
1927
+ * Call this after appending to the log
1928
+ */
1929
+ export function maybeSummarize(cwd = process.cwd()) {
1930
+ const paths = ensureMemoryDirs(cwd);
1931
+ const config = loadConfig();
1932
+
1933
+ // Quick check: does log exist and have enough entries?
1934
+ if (!existsSync(paths.log)) {
1935
+ return;
1936
+ }
1937
+
1938
+ try {
1939
+ const logContent = readFileSync(paths.log, 'utf-8').trim();
1940
+ if (!logContent) return;
1941
+
1942
+ const entryCount = logContent.split('\n').filter(l => l).length;
1943
+
1944
+ if (entryCount < config.maxLogEntriesBeforeSummarize) {
1945
+ return;
1946
+ }
1947
+
1948
+ // Acquire lock atomically using O_EXCL (fails if file already exists)
1949
+ const lockFile = paths.log + '.lock';
1950
+ try {
1951
+ // If lock exists, check if it's stale
1952
+ if (existsSync(lockFile)) {
1953
+ const lockContent = readFileSync(lockFile, 'utf-8').trim();
1954
+ const lockTime = parseInt(lockContent, 10);
1955
+ if (lockTime && Date.now() - lockTime < 5 * 60 * 1000) {
1956
+ return; // Lock is fresh, summarization already running
1957
+ }
1958
+ // Stale lock — remove it so we can try to acquire
1959
+ try { unlinkSync(lockFile); } catch {}
1960
+ }
1961
+
1962
+ // Atomic create: O_CREAT | O_EXCL | O_WRONLY fails if another process created it first
1963
+ const fd = openSync(lockFile, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
1964
+ const timestamp = Buffer.from(Date.now().toString());
1965
+ writeSync(fd, timestamp);
1966
+ closeSync(fd);
1967
+ } catch {
1968
+ // Another process won the race — let it handle summarization
1969
+ return;
1970
+ }
1971
+
1972
+ // Spawn summarize.mjs in background
1973
+ const __filename = fileURLToPath(import.meta.url);
1974
+ const __dirname = dirname(__filename);
1975
+ const summarizeScript = join(__dirname, 'summarize.mjs');
1976
+
1977
+ const child = spawn('node', [summarizeScript, cwd], {
1978
+ detached: true,
1979
+ stdio: 'ignore',
1980
+ cwd: cwd
1981
+ });
1982
+
1983
+ child.unref();
1984
+ } catch (e) {
1985
+ logError(e, 'maybeSummarize');
1986
+ }
1987
+ }
1988
+
1989
+ // ============================================================================
1990
+ // Dependency Management
1991
+ // ============================================================================
1992
+
1993
+ /**
1994
+ * Ensure npm dependencies are installed in the plugin directory.
1995
+ * Checks for the SDK package and runs `npm install` if missing.
1996
+ * @returns {boolean} true if deps are available, false if install failed
1997
+ */
1998
+ export function ensureDeps() {
1999
+ const __filename = fileURLToPath(import.meta.url);
2000
+ const pluginRoot = join(dirname(__filename), '..');
2001
+ const sdkPath = join(pluginRoot, 'node_modules', '@anthropic-ai', 'claude-agent-sdk');
2002
+
2003
+ if (existsSync(sdkPath)) {
2004
+ return true;
2005
+ }
2006
+
2007
+ try {
2008
+ execFileSync('npm', ['install', '--omit=dev'], {
2009
+ cwd: pluginRoot,
2010
+ stdio: 'ignore',
2011
+ timeout: 60000
2012
+ });
2013
+ return existsSync(sdkPath);
2014
+ } catch (error) {
2015
+ logError(error, 'ensureDeps');
2016
+ return false;
2017
+ }
2018
+ }
2019
+
2020
+ // ============================================================================
2021
+ // Error Logging
2022
+ // ============================================================================
2023
+
2024
+ /**
2025
+ * Get the path to the error log file
2026
+ */
2027
+ export function getErrorLogPath() {
2028
+ return join(MEMORY_BASE, 'errors.log');
2029
+ }
2030
+
2031
+ /**
2032
+ * Log an error to the error log file
2033
+ * @param {Error|string} error - The error to log
2034
+ * @param {string} context - Context about where the error occurred (e.g., 'session-start', 'sync')
2035
+ */
2036
+ export function logError(error, context = 'unknown') {
2037
+ try {
2038
+ const errorLogPath = getErrorLogPath();
2039
+ const timestamp = new Date().toISOString();
2040
+ const message = error instanceof Error ? error.message : String(error);
2041
+ const stack = error instanceof Error ? error.stack : null;
2042
+
2043
+ const entry = {
2044
+ ts: timestamp,
2045
+ context,
2046
+ message,
2047
+ stack: stack ? stack.split('\n').slice(1, 4).map(l => l.trim()).join(' | ') : null
2048
+ };
2049
+
2050
+ // Ensure base directory exists
2051
+ if (!existsSync(MEMORY_BASE)) {
2052
+ mkdirSync(MEMORY_BASE, { recursive: true });
2053
+ }
2054
+
2055
+ // Append to error log
2056
+ appendFileSync(errorLogPath, JSON.stringify(entry) + '\n');
2057
+
2058
+ // Rotate log if it gets too large (keep last 100 errors)
2059
+ rotateErrorLog(errorLogPath, 100);
2060
+ } catch {
2061
+ // Can't log the error - fail silently
2062
+ }
2063
+ }
2064
+
2065
+ /**
2066
+ * Rotate error log to keep only the last N entries
2067
+ */
2068
+ function rotateErrorLog(logPath, maxEntries) {
2069
+ try {
2070
+ if (!existsSync(logPath)) return;
2071
+
2072
+ const content = readFileSync(logPath, 'utf-8').trim();
2073
+ if (!content) return;
2074
+
2075
+ const lines = content.split('\n').filter(l => l);
2076
+ if (lines.length > maxEntries) {
2077
+ const trimmed = lines.slice(-maxEntries).join('\n') + '\n';
2078
+ writeFileSync(logPath, trimmed);
2079
+ }
2080
+ } catch {
2081
+ // Ignore rotation errors
2082
+ }
2083
+ }
2084
+
2085
+ /**
2086
+ * Get recent errors from the error log
2087
+ * @param {number} maxCount - Maximum number of errors to return
2088
+ * @returns {Array} Recent error entries
2089
+ */
2090
+ export function getRecentErrors(maxCount = 10) {
2091
+ try {
2092
+ const errorLogPath = getErrorLogPath();
2093
+ if (!existsSync(errorLogPath)) {
2094
+ return [];
2095
+ }
2096
+
2097
+ const content = readFileSync(errorLogPath, 'utf-8').trim();
2098
+ if (!content) return [];
2099
+
2100
+ const lines = content.split('\n').filter(l => l);
2101
+ const errors = lines
2102
+ .map(line => {
2103
+ try { return JSON.parse(line); }
2104
+ catch { return null; }
2105
+ })
2106
+ .filter(Boolean);
2107
+
2108
+ return errors.slice(-maxCount).reverse(); // Most recent first
2109
+ } catch {
2110
+ return [];
2111
+ }
2112
+ }
2113
+
2114
+ /**
2115
+ * Clear the error log
2116
+ */
2117
+ export function clearErrorLog() {
2118
+ try {
2119
+ const errorLogPath = getErrorLogPath();
2120
+ if (existsSync(errorLogPath)) {
2121
+ writeFileSync(errorLogPath, '');
2122
+ }
2123
+ return true;
2124
+ } catch {
2125
+ return false;
2126
+ }
2127
+ }
2128
+
2129
+ /**
2130
+ * Get errors from the last N hours
2131
+ * @param {number} hours - Number of hours to look back
2132
+ * @returns {Array} Errors within the time window
2133
+ */
2134
+ export function getErrorsSince(hours = 24) {
2135
+ const errors = getRecentErrors(100);
2136
+ const cutoff = Date.now() - (hours * 60 * 60 * 1000);
2137
+
2138
+ return errors.filter(e => {
2139
+ const errorTime = new Date(e.ts).getTime();
2140
+ return errorTime >= cutoff;
2141
+ });
2142
+ }