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.
- package/.claude-plugin/plugin.json +17 -0
- package/CLAUDE.md +98 -0
- package/CONFIG_REFERENCE.md +495 -0
- package/README.md +40 -0
- package/commands/entity.md +64 -0
- package/commands/forget.md +69 -0
- package/commands/remember.md +60 -0
- package/commands/status.md +90 -0
- package/commands/summarize.md +69 -0
- package/hooks/hooks.json +123 -0
- package/package.json +12 -0
- package/scripts/mem-add.mjs +59 -0
- package/scripts/mem-entity.mjs +143 -0
- package/scripts/mem-forget.mjs +245 -0
- package/scripts/mem-status.mjs +319 -0
- package/scripts/mem-summarize.mjs +338 -0
- package/scripts/post-compact.mjs +132 -0
- package/scripts/post-tool-use.mjs +353 -0
- package/scripts/pre-compact.mjs +491 -0
- package/scripts/session-start.mjs +283 -0
- package/scripts/session-stop.mjs +31 -0
- package/scripts/stop-capture.mjs +294 -0
- package/scripts/subagent-stop.mjs +203 -0
- package/scripts/summarize.mjs +428 -0
- package/scripts/sync.mjs +609 -0
- package/scripts/user-prompt-submit.mjs +77 -0
- package/scripts/utils.mjs +2142 -0
- package/scripts/utils.test.mjs +1465 -0
|
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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  → 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
|
+
}
|