@exaudeus/memory-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/dist/__tests__/clock-and-validators.test.d.ts +1 -0
  4. package/dist/__tests__/clock-and-validators.test.js +237 -0
  5. package/dist/__tests__/config-manager.test.d.ts +1 -0
  6. package/dist/__tests__/config-manager.test.js +142 -0
  7. package/dist/__tests__/config.test.d.ts +1 -0
  8. package/dist/__tests__/config.test.js +236 -0
  9. package/dist/__tests__/crash-journal.test.d.ts +1 -0
  10. package/dist/__tests__/crash-journal.test.js +203 -0
  11. package/dist/__tests__/e2e.test.d.ts +1 -0
  12. package/dist/__tests__/e2e.test.js +788 -0
  13. package/dist/__tests__/ephemeral-benchmark.test.d.ts +1 -0
  14. package/dist/__tests__/ephemeral-benchmark.test.js +651 -0
  15. package/dist/__tests__/ephemeral.test.d.ts +1 -0
  16. package/dist/__tests__/ephemeral.test.js +435 -0
  17. package/dist/__tests__/git-service.test.d.ts +1 -0
  18. package/dist/__tests__/git-service.test.js +43 -0
  19. package/dist/__tests__/normalize.test.d.ts +1 -0
  20. package/dist/__tests__/normalize.test.js +161 -0
  21. package/dist/__tests__/store.test.d.ts +1 -0
  22. package/dist/__tests__/store.test.js +1153 -0
  23. package/dist/config-manager.d.ts +49 -0
  24. package/dist/config-manager.js +126 -0
  25. package/dist/config.d.ts +32 -0
  26. package/dist/config.js +162 -0
  27. package/dist/crash-journal.d.ts +38 -0
  28. package/dist/crash-journal.js +198 -0
  29. package/dist/ephemeral-weights.json +1847 -0
  30. package/dist/ephemeral.d.ts +20 -0
  31. package/dist/ephemeral.js +516 -0
  32. package/dist/formatters.d.ts +10 -0
  33. package/dist/formatters.js +92 -0
  34. package/dist/git-service.d.ts +5 -0
  35. package/dist/git-service.js +39 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.js +1197 -0
  38. package/dist/normalize.d.ts +2 -0
  39. package/dist/normalize.js +69 -0
  40. package/dist/store.d.ts +84 -0
  41. package/dist/store.js +813 -0
  42. package/dist/text-analyzer.d.ts +32 -0
  43. package/dist/text-analyzer.js +190 -0
  44. package/dist/thresholds.d.ts +39 -0
  45. package/dist/thresholds.js +75 -0
  46. package/dist/types.d.ts +186 -0
  47. package/dist/types.js +33 -0
  48. package/package.json +57 -0
package/dist/store.js ADDED
@@ -0,0 +1,813 @@
1
+ // Markdown-backed memory store — one file per entry
2
+ // Concurrency-safe: no two processes write the same file (except corrections)
3
+ // Worktree-safe: shared storage via .git common dir, branch-scoped recent-work
4
+ import { promises as fs } from 'fs';
5
+ import path from 'path';
6
+ import crypto from 'crypto';
7
+ import { execFile } from 'child_process';
8
+ import { promisify } from 'util';
9
+ import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel } from './types.js';
10
+ import { DEDUP_SIMILARITY_THRESHOLD, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD, REFERENCE_BOOST_MULTIPLIER, TOPIC_BOOST, MODULE_TOPIC_BOOST, USER_ALWAYS_INCLUDE_SCORE_FRACTION, DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, DEFAULT_MAX_PREFERENCE_SUGGESTIONS, } from './thresholds.js';
11
+ import { realGitService } from './git-service.js';
12
+ import { extractKeywords, stem, similarity, matchesFilter, computeRelevanceScore, } from './text-analyzer.js';
13
+ import { detectEphemeralSignals, formatEphemeralWarning } from './ephemeral.js';
14
+ // Used only by bootstrap() for git log — not part of the GitService boundary
15
+ // because bootstrap is a one-shot utility, not a recurring operation
16
+ const execFileAsync = promisify(execFile);
17
+ export class MarkdownMemoryStore {
18
+ constructor(config) {
19
+ this.entries = new Map();
20
+ this.corruptFileCount = 0;
21
+ this.config = config;
22
+ this.memoryPath = config.memoryPath;
23
+ this.clock = config.clock ?? realClock;
24
+ this.git = config.git ?? realGitService;
25
+ }
26
+ /** Resolved behavior thresholds — user config merged over defaults.
27
+ * Centralizes threshold resolution so every caller gets the same value. */
28
+ get behavior() {
29
+ const b = this.config.behavior ?? {};
30
+ return {
31
+ staleDaysStandard: b.staleDaysStandard ?? DEFAULT_STALE_DAYS_STANDARD,
32
+ staleDaysPreferences: b.staleDaysPreferences ?? DEFAULT_STALE_DAYS_PREFERENCES,
33
+ maxStaleInBriefing: b.maxStaleInBriefing ?? DEFAULT_MAX_STALE_IN_BRIEFING,
34
+ maxDedupSuggestions: b.maxDedupSuggestions ?? DEFAULT_MAX_DEDUP_SUGGESTIONS,
35
+ maxConflictPairs: b.maxConflictPairs ?? DEFAULT_MAX_CONFLICT_PAIRS,
36
+ };
37
+ }
38
+ /** Initialize the store: create memory dir and load existing entries */
39
+ async init() {
40
+ await fs.mkdir(this.memoryPath, { recursive: true });
41
+ await this.reloadFromDisk();
42
+ }
43
+ /** Store a new knowledge entry */
44
+ async store(topic, title, content, sources = [], trust = 'agent-inferred', references = []) {
45
+ // Check storage budget — null means we can't measure, allow the write
46
+ const currentSize = await this.getStorageSize();
47
+ if (currentSize !== null && currentSize >= this.config.storageBudgetBytes) {
48
+ return {
49
+ stored: false, topic,
50
+ warning: `Storage budget exceeded (${this.formatBytes(currentSize)} / ${this.formatBytes(this.config.storageBudgetBytes)}). Delete or correct existing entries to free space.`,
51
+ };
52
+ }
53
+ const id = this.generateId(topic);
54
+ const now = this.clock.isoNow();
55
+ const confidence = DEFAULT_CONFIDENCE[trust];
56
+ const gitSha = await this.getGitSha(sources);
57
+ // Auto-detect branch for recent-work entries
58
+ const branch = topic === 'recent-work' ? await this.getCurrentBranch() : undefined;
59
+ const entry = {
60
+ id, topic, title, content, confidence, trust,
61
+ sources,
62
+ references: references.length > 0 ? references : undefined,
63
+ created: now, lastAccessed: now, gitSha, branch,
64
+ };
65
+ // Check for existing entry with same title in same topic (and same branch for recent-work)
66
+ const existing = Array.from(this.entries.values())
67
+ .find(e => e.topic === topic && e.title === title && (topic !== 'recent-work' || e.branch === branch));
68
+ const warning = existing
69
+ ? `Overwrote existing entry '${title}' (id: ${existing.id}).`
70
+ : undefined;
71
+ if (existing) {
72
+ await this.deleteEntryFile(existing);
73
+ this.entries.delete(existing.id);
74
+ }
75
+ // Store entry in memory and on disk
76
+ this.entries.set(id, entry);
77
+ const file = this.entryToRelativePath(entry);
78
+ await this.persistEntry(entry);
79
+ // Dedup: find related entries in the same topic (excluding the one just stored and any overwritten)
80
+ const relatedEntries = this.findRelatedEntries(entry, existing?.id);
81
+ // Surface relevant preferences if storing a non-preference entry
82
+ const relevantPreferences = (topic !== 'preferences' && topic !== 'user')
83
+ ? this.findRelevantPreferences(entry)
84
+ : undefined;
85
+ // Soft ephemeral detection — warn but never block
86
+ const ephemeralSignals = topic !== 'recent-work'
87
+ ? detectEphemeralSignals(title, content, topic)
88
+ : [];
89
+ const ephemeralWarning = formatEphemeralWarning(ephemeralSignals);
90
+ return {
91
+ stored: true, id, topic, file, confidence, warning, ephemeralWarning,
92
+ relatedEntries: relatedEntries.length > 0 ? relatedEntries : undefined,
93
+ relevantPreferences: relevantPreferences && relevantPreferences.length > 0 ? relevantPreferences : undefined,
94
+ };
95
+ }
96
+ /** Query knowledge by scope and detail level */
97
+ async query(scope, detail = 'brief', filter, branchFilter) {
98
+ // Reload from disk to pick up changes from other processes
99
+ await this.reloadFromDisk();
100
+ const currentBranch = await this.getCurrentBranch();
101
+ const matching = Array.from(this.entries.values()).filter(entry => {
102
+ // Scope matching
103
+ if (scope !== '*' && entry.topic !== scope) {
104
+ if (!entry.topic.startsWith(scope + '/') && entry.topic !== scope) {
105
+ return false;
106
+ }
107
+ }
108
+ // Branch filtering for recent-work: default to current branch
109
+ // branchFilter: undefined = current branch, '*' = all branches, 'name' = specific branch
110
+ if (entry.topic === 'recent-work' && branchFilter !== '*') {
111
+ const targetBranch = branchFilter ?? currentBranch;
112
+ if (targetBranch && entry.branch && entry.branch !== targetBranch) {
113
+ return false;
114
+ }
115
+ }
116
+ // Optional keyword filter with AND/OR/NOT syntax
117
+ if (filter) {
118
+ const titleKeywords = extractKeywords(entry.title);
119
+ const contentKeywords = extractKeywords(entry.content);
120
+ const allKeywords = new Set([...titleKeywords, ...contentKeywords]);
121
+ return matchesFilter(allKeywords, filter);
122
+ }
123
+ return true;
124
+ });
125
+ // Sort by relevance score (title-weighted), then confidence, then recency
126
+ if (filter) {
127
+ const scores = new Map();
128
+ for (const entry of matching) {
129
+ scores.set(entry.id, computeRelevanceScore(extractKeywords(entry.title), extractKeywords(entry.content), entry.confidence, filter));
130
+ }
131
+ matching.sort((a, b) => {
132
+ const scoreDiff = (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0);
133
+ if (Math.abs(scoreDiff) > 0.01)
134
+ return scoreDiff;
135
+ if (b.confidence !== a.confidence)
136
+ return b.confidence - a.confidence;
137
+ return new Date(b.lastAccessed).getTime() - new Date(a.lastAccessed).getTime();
138
+ });
139
+ }
140
+ else {
141
+ matching.sort((a, b) => {
142
+ if (b.confidence !== a.confidence)
143
+ return b.confidence - a.confidence;
144
+ return new Date(b.lastAccessed).getTime() - new Date(a.lastAccessed).getTime();
145
+ });
146
+ }
147
+ // Update lastAccessed for queried entries
148
+ const now = this.clock.isoNow();
149
+ for (const entry of matching) {
150
+ const updated = { ...entry, lastAccessed: now };
151
+ this.entries.set(entry.id, updated);
152
+ // Fire-and-forget persist — don't block the query
153
+ this.persistEntry(updated).catch(() => { });
154
+ }
155
+ const entries = matching.map(entry => ({
156
+ ...this.formatEntry(entry, detail),
157
+ relevanceScore: filter
158
+ ? computeRelevanceScore(extractKeywords(entry.title), extractKeywords(entry.content), entry.confidence, filter)
159
+ : entry.confidence,
160
+ }));
161
+ return { scope, detail, entries, totalEntries: matching.length };
162
+ }
163
+ /** Generate a session-start briefing */
164
+ async briefing(maxTokens = 200) {
165
+ // Reload from disk to pick up changes from other processes
166
+ await this.reloadFromDisk();
167
+ if (this.entries.size === 0) {
168
+ return {
169
+ briefing: 'No knowledge stored yet. Use memory_store to save observations, or memory_bootstrap to scan the codebase.',
170
+ entryCount: 0, staleEntries: 0,
171
+ suggestion: 'Try memory_bootstrap to seed initial knowledge from the codebase structure.',
172
+ };
173
+ }
174
+ const currentBranch = await this.getCurrentBranch();
175
+ const allEntries = Array.from(this.entries.values());
176
+ const staleCount = allEntries.filter(e => !this.isFresh(e)).length;
177
+ // Group by topic, filter recent-work by current branch
178
+ const byTopic = new Map();
179
+ for (const entry of allEntries) {
180
+ // Skip recent-work from other branches
181
+ if (entry.topic === 'recent-work' && entry.branch && entry.branch !== currentBranch) {
182
+ continue;
183
+ }
184
+ const list = byTopic.get(entry.topic) ?? [];
185
+ list.push(entry);
186
+ byTopic.set(entry.topic, list);
187
+ }
188
+ // Priority order: user > preferences > gotchas > architecture > conventions > modules > recent-work
189
+ const topicOrder = ['user', 'preferences', 'gotchas', 'architecture', 'conventions'];
190
+ const moduleTopics = Array.from(byTopic.keys()).filter(t => t.startsWith('modules/')).sort();
191
+ topicOrder.push(...moduleTopics, 'recent-work');
192
+ const sections = [];
193
+ let estimatedTokens = 0;
194
+ for (const topic of topicOrder) {
195
+ const topicEntries = byTopic.get(topic);
196
+ if (!topicEntries || topicEntries.length === 0)
197
+ continue;
198
+ const heading = topic === 'user' ? 'About You'
199
+ : topic === 'preferences' ? 'Your Preferences'
200
+ : topic === 'gotchas' ? 'Active Gotchas'
201
+ : topic === 'recent-work' ? `Recent Work (${currentBranch})`
202
+ : topic.startsWith('modules/') ? `Module: ${topic.split('/')[1]}`
203
+ : topic.charAt(0).toUpperCase() + topic.slice(1);
204
+ const lines = topicEntries
205
+ .sort((a, b) => b.confidence - a.confidence)
206
+ .map(e => {
207
+ const staleMarker = this.isFresh(e) ? '' : ' [stale]';
208
+ const gotchaMarker = topic === 'gotchas' ? '[!] ' : '';
209
+ return `- ${gotchaMarker}${e.title}: ${this.summarize(e.content, 80)}${staleMarker}`;
210
+ });
211
+ const section = `### ${heading}\n${lines.join('\n')}`;
212
+ const sectionTokens = Math.ceil(section.length / 4);
213
+ if (estimatedTokens + sectionTokens > maxTokens && sections.length > 0)
214
+ break;
215
+ sections.push(section);
216
+ estimatedTokens += sectionTokens;
217
+ }
218
+ const briefing = `## Session Briefing\n\n${sections.join('\n\n')}`;
219
+ const recentWork = byTopic.get('recent-work');
220
+ const suggestion = recentWork && recentWork.length > 0
221
+ ? `Last session context available for branch "${currentBranch}". Try memory_query(scope: "recent-work", detail: "full") for details.`
222
+ : undefined;
223
+ // Collect stale details for the presentation layer (index.ts) to format.
224
+ // Topic priority order: gotchas first (most dangerous when stale), then arch/conv/modules/recent-work.
225
+ // User is never stale; preferences use a 90-day threshold so they'll appear here only when truly old.
226
+ const STALE_TOPIC_PRIORITY = {
227
+ gotchas: 0, architecture: 1, conventions: 2, 'recent-work': 4,
228
+ };
229
+ const staleDetails = allEntries
230
+ .filter(e => !this.isFresh(e))
231
+ .map(e => ({
232
+ id: e.id,
233
+ title: e.title,
234
+ topic: e.topic,
235
+ daysSinceAccess: Math.floor(this.daysSinceAccess(e)),
236
+ }))
237
+ .sort((a, b) => {
238
+ const pa = STALE_TOPIC_PRIORITY[a.topic] ?? (a.topic.startsWith('modules/') ? 3 : 5);
239
+ const pb = STALE_TOPIC_PRIORITY[b.topic] ?? (b.topic.startsWith('modules/') ? 3 : 5);
240
+ if (pa !== pb)
241
+ return pa - pb;
242
+ return b.daysSinceAccess - a.daysSinceAccess; // older entries first within same priority
243
+ })
244
+ .slice(0, this.behavior.maxStaleInBriefing); // cap to avoid overwhelming the agent
245
+ return {
246
+ briefing,
247
+ entryCount: this.entries.size,
248
+ staleEntries: staleCount,
249
+ staleDetails: staleDetails.length > 0 ? staleDetails : undefined,
250
+ suggestion,
251
+ };
252
+ }
253
+ /** Correct an existing entry */
254
+ async correct(id, correction, action) {
255
+ // Reload to ensure we have the latest
256
+ await this.reloadFromDisk();
257
+ const entry = this.entries.get(id);
258
+ if (!entry) {
259
+ return {
260
+ corrected: false, id,
261
+ error: `Entry not found: ${id}`,
262
+ };
263
+ }
264
+ if (action === 'delete') {
265
+ await this.deleteEntryFile(entry);
266
+ this.entries.delete(id);
267
+ return { corrected: true, id, action, newConfidence: 0, trust: 'user' };
268
+ }
269
+ const newContent = action === 'append'
270
+ ? `${entry.content}\n\n${correction}`
271
+ : correction;
272
+ const updated = {
273
+ ...entry,
274
+ content: newContent,
275
+ confidence: 1.0,
276
+ trust: 'user',
277
+ lastAccessed: this.clock.isoNow(),
278
+ };
279
+ this.entries.set(id, updated);
280
+ await this.persistEntry(updated);
281
+ return { corrected: true, id, action, newConfidence: 1.0, trust: 'user' };
282
+ }
283
+ /** Get memory health statistics */
284
+ async stats() {
285
+ await this.reloadFromDisk();
286
+ const allEntries = Array.from(this.entries.values());
287
+ const storageSize = await this.getStorageSize();
288
+ const byTopic = {};
289
+ const byTrust = { 'user': 0, 'agent-confirmed': 0, 'agent-inferred': 0 };
290
+ const byFreshness = { fresh: 0, stale: 0, unknown: 0 };
291
+ for (const entry of allEntries) {
292
+ byTopic[entry.topic] = (byTopic[entry.topic] ?? 0) + 1;
293
+ byTrust[entry.trust]++;
294
+ if (entry.sources.length === 0) {
295
+ byFreshness.unknown++;
296
+ }
297
+ else if (this.isFresh(entry)) {
298
+ byFreshness.fresh++;
299
+ }
300
+ else {
301
+ byFreshness.stale++;
302
+ }
303
+ }
304
+ const dates = allEntries.map(e => e.created).sort();
305
+ return {
306
+ totalEntries: allEntries.length,
307
+ corruptFiles: this.corruptFileCount,
308
+ byTopic, byTrust, byFreshness,
309
+ storageSize: this.formatBytes(storageSize ?? 0),
310
+ storageBudgetBytes: this.config.storageBudgetBytes,
311
+ memoryPath: this.memoryPath,
312
+ oldestEntry: dates[0],
313
+ newestEntry: dates[dates.length - 1],
314
+ };
315
+ }
316
+ /** Bootstrap: scan repo structure and seed initial knowledge */
317
+ async bootstrap() {
318
+ const results = [];
319
+ const repoRoot = this.config.repoRoot;
320
+ // 1. Scan directory structure
321
+ try {
322
+ const topLevel = await fs.readdir(repoRoot, { withFileTypes: true });
323
+ const dirs = topLevel
324
+ .filter(d => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
325
+ .map(d => d.name);
326
+ if (dirs.length > 0) {
327
+ results.push(await this.store('architecture', 'Repository Structure', `Top-level directories: ${dirs.join(', ')}`, [], 'agent-inferred'));
328
+ }
329
+ }
330
+ catch { /* ignore */ }
331
+ // 2. Read README if it exists
332
+ try {
333
+ const readme = await fs.readFile(path.join(repoRoot, 'README.md'), 'utf-8');
334
+ results.push(await this.store('architecture', 'README Summary', this.summarize(readme, 500), ['README.md'], 'agent-inferred'));
335
+ }
336
+ catch { /* no README */ }
337
+ // 3. Detect build system / language
338
+ const buildFiles = [
339
+ { file: 'package.json', meaning: 'Node.js/TypeScript project (npm)' },
340
+ { file: 'Package.swift', meaning: 'Swift Package Manager project' },
341
+ { file: 'build.gradle.kts', meaning: 'Kotlin/Gradle project' },
342
+ { file: 'build.gradle', meaning: 'Java/Gradle project' },
343
+ { file: 'Cargo.toml', meaning: 'Rust project (Cargo)' },
344
+ { file: 'go.mod', meaning: 'Go module' },
345
+ { file: 'pyproject.toml', meaning: 'Python project' },
346
+ { file: 'Tuist.swift', meaning: 'iOS project managed by Tuist' },
347
+ ];
348
+ const detected = [];
349
+ for (const { file, meaning } of buildFiles) {
350
+ try {
351
+ await fs.access(path.join(repoRoot, file));
352
+ detected.push(meaning);
353
+ }
354
+ catch { /* not found */ }
355
+ }
356
+ if (detected.length > 0) {
357
+ results.push(await this.store('architecture', 'Build System & Language', `Detected: ${detected.join('; ')}`, detected.map(d => buildFiles.find(b => b.meaning === d)?.file ?? '').filter(Boolean), 'agent-inferred'));
358
+ }
359
+ // 4. Check git info
360
+ try {
361
+ const { stdout } = await execFileAsync('git', ['log', '--oneline', '-5'], { cwd: repoRoot, timeout: 5000 });
362
+ results.push(await this.store('recent-work', 'Recent Git History', `Last 5 commits:\n${stdout.trim()}`, [], 'agent-inferred'));
363
+ }
364
+ catch { /* not a git repo or git not available */ }
365
+ return results;
366
+ }
367
+ // --- Contextual search (memory_context) ---
368
+ /** Search across all topics using keyword matching with topic-based boosting.
369
+ * @param minMatch Minimum ratio of context keywords that must match (0-1, default 0.2) */
370
+ async contextSearch(context, maxResults = 10, branchFilter, minMatch = 0.2) {
371
+ // Reload from disk to pick up changes from other processes
372
+ await this.reloadFromDisk();
373
+ const contextKeywords = extractKeywords(context);
374
+ if (contextKeywords.size === 0)
375
+ return [];
376
+ const currentBranch = branchFilter || await this.getCurrentBranch();
377
+ // Topic boost factors — higher = more likely to surface
378
+ const topicBoost = TOPIC_BOOST;
379
+ const results = [];
380
+ for (const entry of this.entries.values()) {
381
+ // Filter recent-work by branch (unless branchFilter is "*")
382
+ if (entry.topic === 'recent-work' && branchFilter !== '*' && entry.branch && entry.branch !== currentBranch) {
383
+ continue;
384
+ }
385
+ const entryKeywords = extractKeywords(`${entry.title} ${entry.content}`);
386
+ const matchedKeywords = [];
387
+ for (const kw of contextKeywords) {
388
+ if (entryKeywords.has(kw))
389
+ matchedKeywords.push(kw);
390
+ }
391
+ if (matchedKeywords.length === 0)
392
+ continue;
393
+ // Enforce minimum match threshold
394
+ const matchRatio = matchedKeywords.length / contextKeywords.size;
395
+ if (matchRatio < minMatch)
396
+ continue;
397
+ // Score = keyword match ratio x confidence x topic boost x reference boost
398
+ const boost = topicBoost[entry.topic] ?? (entry.topic.startsWith('modules/') ? MODULE_TOPIC_BOOST : 1.0);
399
+ const freshnessMultiplier = this.isFresh(entry) ? 1.0 : 0.7;
400
+ // Reference boost: exact class/file name match in references gets a 1.3x multiplier.
401
+ // Extracts the basename (without extension) from each reference path and stems it,
402
+ // then checks for overlap with the context keywords.
403
+ const referenceBoost = entry.references?.some(ref => {
404
+ const basename = ref.split('/').pop()?.replace(/\.\w+$/, '') ?? ref;
405
+ return contextKeywords.has(stem(basename.toLowerCase()));
406
+ }) ? REFERENCE_BOOST_MULTIPLIER : 1.0;
407
+ const score = matchRatio * entry.confidence * boost * freshnessMultiplier * referenceBoost;
408
+ results.push({ entry, score, matchedKeywords });
409
+ }
410
+ // Always include user entries even if no keyword match (they're always relevant)
411
+ for (const entry of this.entries.values()) {
412
+ if (entry.topic === 'user' && !results.find(r => r.entry.id === entry.id)) {
413
+ results.push({ entry, score: entry.confidence * USER_ALWAYS_INCLUDE_SCORE_FRACTION, matchedKeywords: [] });
414
+ }
415
+ }
416
+ return results
417
+ .sort((a, b) => b.score - a.score)
418
+ .slice(0, maxResults);
419
+ }
420
+ // --- Private helpers ---
421
+ /** Generate a collision-resistant ID: {prefix}-{8 random hex chars} */
422
+ generateId(topic) {
423
+ const prefix = topic.startsWith('modules/') ? 'mod' :
424
+ topic === 'user' ? 'user' :
425
+ topic === 'preferences' ? 'pref' :
426
+ topic === 'architecture' ? 'arch' :
427
+ topic === 'conventions' ? 'conv' :
428
+ topic === 'gotchas' ? 'gotcha' :
429
+ topic === 'recent-work' ? 'recent' : 'mem';
430
+ const hex = crypto.randomBytes(4).toString('hex');
431
+ return `${prefix}-${hex}`;
432
+ }
433
+ /** Compute relative file path for an entry within the memory directory */
434
+ entryToRelativePath(entry) {
435
+ if (entry.topic === 'recent-work' && entry.branch) {
436
+ const branchSlug = this.sanitizeBranchName(entry.branch);
437
+ return path.join('recent-work', branchSlug, `${entry.id}.md`);
438
+ }
439
+ return path.join(entry.topic, `${entry.id}.md`);
440
+ }
441
+ /** Sanitize git branch name for use as a directory name */
442
+ sanitizeBranchName(branch) {
443
+ return branch
444
+ .replace(/[^a-zA-Z0-9._-]/g, '-') // replace non-safe chars with dash
445
+ .replace(/-+/g, '-') // collapse consecutive dashes
446
+ .replace(/^-|-$/g, '') // trim leading/trailing dashes
447
+ || 'unknown';
448
+ }
449
+ /** Get the current git branch name — delegates to injected GitService */
450
+ async getCurrentBranch() {
451
+ return this.git.getCurrentBranch(this.config.repoRoot);
452
+ }
453
+ /** Write a single entry to its own file */
454
+ async persistEntry(entry) {
455
+ const relativePath = this.entryToRelativePath(entry);
456
+ const fullPath = path.join(this.memoryPath, relativePath);
457
+ const meta = [
458
+ `- **id**: ${entry.id}`,
459
+ `- **topic**: ${entry.topic}`,
460
+ `- **confidence**: ${entry.confidence}`,
461
+ `- **trust**: ${entry.trust}`,
462
+ `- **created**: ${entry.created}`,
463
+ `- **lastAccessed**: ${entry.lastAccessed}`,
464
+ ];
465
+ if (entry.sources.length > 0) {
466
+ meta.push(`- **source**: ${entry.sources.join(', ')}`);
467
+ }
468
+ if (entry.references && entry.references.length > 0) {
469
+ meta.push(`- **references**: ${entry.references.join(', ')}`);
470
+ }
471
+ if (entry.gitSha) {
472
+ meta.push(`- **gitSha**: ${entry.gitSha}`);
473
+ }
474
+ if (entry.branch) {
475
+ meta.push(`- **branch**: ${entry.branch}`);
476
+ }
477
+ const content = `# ${entry.title}\n${meta.join('\n')}\n\n${entry.content}\n`;
478
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
479
+ await fs.writeFile(fullPath, content, 'utf-8');
480
+ }
481
+ /** Delete the file for an entry */
482
+ async deleteEntryFile(entry) {
483
+ const relativePath = this.entryToRelativePath(entry);
484
+ const fullPath = path.join(this.memoryPath, relativePath);
485
+ try {
486
+ await fs.unlink(fullPath);
487
+ }
488
+ catch { /* already gone */ }
489
+ // Clean up empty parent directories
490
+ try {
491
+ const dir = path.dirname(fullPath);
492
+ const remaining = await fs.readdir(dir);
493
+ if (remaining.length === 0 && dir !== this.memoryPath) {
494
+ await fs.rmdir(dir);
495
+ }
496
+ }
497
+ catch { /* ignore */ }
498
+ }
499
+ /** Load all entries from disk and return as an immutable snapshot.
500
+ * Pure read — no mutation. Callers decide whether to cache.
501
+ * Tracks corrupt files for observability without failing the load. */
502
+ async loadSnapshot() {
503
+ const entries = new Map();
504
+ let corruptFileCount = 0;
505
+ try {
506
+ const files = await this.findMarkdownFiles(this.memoryPath);
507
+ for (const file of files) {
508
+ const content = await fs.readFile(file, 'utf-8');
509
+ const entry = this.parseSingleEntry(content);
510
+ if (entry) {
511
+ entries.set(entry.id, entry);
512
+ }
513
+ else {
514
+ corruptFileCount++;
515
+ }
516
+ }
517
+ }
518
+ catch {
519
+ // Empty memory — first run
520
+ }
521
+ return { entries, corruptFileCount };
522
+ }
523
+ /** Reload entries from disk into the store's working state.
524
+ * This is the single mutation point for disk reads. */
525
+ async reloadFromDisk() {
526
+ const snapshot = await this.loadSnapshot();
527
+ this.entries = new Map(snapshot.entries);
528
+ this.corruptFileCount = snapshot.corruptFileCount;
529
+ }
530
+ /** Recursively find all .md files in a directory */
531
+ async findMarkdownFiles(dir) {
532
+ const results = [];
533
+ try {
534
+ const entries = await fs.readdir(dir, { withFileTypes: true });
535
+ for (const entry of entries) {
536
+ const fullPath = path.join(dir, entry.name);
537
+ if (entry.isDirectory()) {
538
+ results.push(...await this.findMarkdownFiles(fullPath));
539
+ }
540
+ else if (entry.name.endsWith('.md')) {
541
+ results.push(fullPath);
542
+ }
543
+ }
544
+ }
545
+ catch { /* ignore */ }
546
+ return results;
547
+ }
548
+ /** Parse a single-entry Markdown file (# heading format).
549
+ * Validates topic and trust at the boundary — rejects corrupt files. */
550
+ parseSingleEntry(content) {
551
+ const titleMatch = content.match(/^# (.+)$/m);
552
+ if (!titleMatch)
553
+ return null;
554
+ const title = titleMatch[1].trim();
555
+ const metadata = {};
556
+ const metaRegex = /^- \*\*(\w+)\*\*:\s*(.+)$/gm;
557
+ let match;
558
+ while ((match = metaRegex.exec(content)) !== null) {
559
+ metadata[match[1]] = match[2].trim();
560
+ }
561
+ // Content is everything after the metadata block
562
+ const lines = content.split('\n');
563
+ let contentStart = 0;
564
+ for (let i = 0; i < lines.length; i++) {
565
+ if (lines[i].match(/^- \*\*\w+\*\*:/)) {
566
+ contentStart = i + 1;
567
+ }
568
+ }
569
+ while (contentStart < lines.length && lines[contentStart].trim() === '') {
570
+ contentStart++;
571
+ }
572
+ const entryContent = lines.slice(contentStart).join('\n').trim();
573
+ if (!metadata['id'] || !metadata['topic'] || entryContent.length === 0)
574
+ return null;
575
+ // Validate at boundary — reject entries with invalid topic or trust
576
+ const topic = parseTopicScope(metadata['topic']);
577
+ if (!topic)
578
+ return null;
579
+ const trust = parseTrustLevel(metadata['trust'] ?? 'agent-inferred');
580
+ if (!trust)
581
+ return null;
582
+ const now = this.clock.isoNow();
583
+ // Clamp confidence to valid 0.0-1.0 range at boundary
584
+ const rawConfidence = parseFloat(metadata['confidence'] ?? '0.7');
585
+ const confidence = Math.max(0.0, Math.min(1.0, isNaN(rawConfidence) ? 0.7 : rawConfidence));
586
+ const references = metadata['references']
587
+ ? metadata['references'].split(',').map(s => s.trim()).filter(s => s.length > 0)
588
+ : undefined;
589
+ return {
590
+ id: metadata['id'],
591
+ topic,
592
+ title,
593
+ content: entryContent,
594
+ confidence,
595
+ trust,
596
+ sources: metadata['source'] ? metadata['source'].split(',').map(s => s.trim()) : [],
597
+ references: references && references.length > 0 ? references : undefined,
598
+ created: metadata['created'] ?? now,
599
+ lastAccessed: metadata['lastAccessed'] ?? now,
600
+ gitSha: metadata['gitSha'],
601
+ branch: metadata['branch'],
602
+ };
603
+ }
604
+ formatEntry(entry, detail) {
605
+ const base = {
606
+ id: entry.id,
607
+ title: entry.title,
608
+ summary: detail === 'brief'
609
+ ? this.summarize(entry.content, 100)
610
+ : detail === 'standard'
611
+ ? this.summarize(entry.content, 300)
612
+ : entry.content,
613
+ confidence: entry.confidence,
614
+ relevanceScore: entry.confidence, // default; overridden in query() when filter is present
615
+ fresh: this.isFresh(entry),
616
+ };
617
+ if (detail === 'standard') {
618
+ return {
619
+ ...base,
620
+ // Surface references in standard detail — compact but useful for navigation
621
+ references: entry.references,
622
+ };
623
+ }
624
+ if (detail === 'full') {
625
+ return {
626
+ ...base,
627
+ content: entry.content,
628
+ trust: entry.trust,
629
+ sources: entry.sources,
630
+ references: entry.references,
631
+ created: entry.created,
632
+ lastAccessed: entry.lastAccessed,
633
+ gitSha: entry.gitSha,
634
+ branch: entry.branch,
635
+ };
636
+ }
637
+ return base;
638
+ }
639
+ isFresh(entry) {
640
+ // User identity never goes stale — name, role, employer change on a scale of years
641
+ if (entry.topic === 'user')
642
+ return true;
643
+ const daysSinceAccess = this.daysSinceAccess(entry);
644
+ const { staleDaysStandard, staleDaysPreferences } = this.behavior;
645
+ // Preferences evolve slowly — longer threshold to avoid noisy renewal nudges
646
+ if (entry.topic === 'preferences')
647
+ return daysSinceAccess <= staleDaysPreferences;
648
+ // Everything else (including gotchas) uses the standard threshold.
649
+ // Gotchas are deliberately NOT exempt: code changes make them the most dangerous when stale.
650
+ // Trust level does NOT grant freshness exemption — trust reflects source quality at write time,
651
+ // not temporal validity. A user-confirmed entry from 6 months ago can still be outdated.
652
+ return daysSinceAccess <= staleDaysStandard;
653
+ }
654
+ /** Days elapsed since entry was last accessed */
655
+ daysSinceAccess(entry) {
656
+ const now = this.clock.now().getTime();
657
+ const lastAccessed = new Date(entry.lastAccessed).getTime();
658
+ return (now - lastAccessed) / (1000 * 60 * 60 * 24);
659
+ }
660
+ summarize(text, maxChars) {
661
+ if (text.length <= maxChars)
662
+ return text;
663
+ const truncated = text.substring(0, maxChars);
664
+ const lastSpace = truncated.lastIndexOf(' ');
665
+ return (lastSpace > maxChars * 0.5 ? truncated.substring(0, lastSpace) : truncated) + '...';
666
+ }
667
+ /** Get HEAD SHA for source tracking — delegates to injected GitService */
668
+ async getGitSha(sources) {
669
+ if (sources.length === 0)
670
+ return undefined;
671
+ return this.git.getHeadSha(this.config.repoRoot);
672
+ }
673
+ /** Get total storage size in bytes, or null if unmeasurable */
674
+ async getStorageSize() {
675
+ let totalSize = 0;
676
+ try {
677
+ const files = await this.findMarkdownFiles(this.memoryPath);
678
+ for (const file of files) {
679
+ const stat = await fs.stat(file);
680
+ totalSize += stat.size;
681
+ }
682
+ return totalSize;
683
+ }
684
+ catch {
685
+ return null;
686
+ }
687
+ }
688
+ formatBytes(bytes) {
689
+ if (bytes < 1024)
690
+ return `${bytes}B`;
691
+ if (bytes < 1024 * 1024)
692
+ return `${(bytes / 1024).toFixed(1)}KB`;
693
+ return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
694
+ }
695
+ // --- Dedup and preference surfacing ---
696
+ /** Find entries in the same topic with significant overlap (dedup detection).
697
+ * Uses hybrid jaccard+containment similarity. */
698
+ findRelatedEntries(newEntry, excludeId) {
699
+ const related = [];
700
+ for (const entry of this.entries.values()) {
701
+ if (entry.id === newEntry.id)
702
+ continue;
703
+ if (excludeId && entry.id === excludeId)
704
+ continue;
705
+ if (entry.topic !== newEntry.topic)
706
+ continue;
707
+ const sim = similarity(newEntry.title, newEntry.content, entry.title, entry.content);
708
+ if (sim > DEDUP_SIMILARITY_THRESHOLD) {
709
+ related.push({ entry, similarity: sim });
710
+ }
711
+ }
712
+ return related
713
+ .sort((a, b) => b.similarity - a.similarity)
714
+ .slice(0, this.behavior.maxDedupSuggestions)
715
+ .map(r => ({
716
+ id: r.entry.id,
717
+ title: r.entry.title,
718
+ content: r.entry.content,
719
+ confidence: r.entry.confidence,
720
+ trust: r.entry.trust,
721
+ }));
722
+ }
723
+ /** Fetch raw MemoryEntry objects by ID for conflict detection.
724
+ * Must be called after query() (which calls reloadFromDisk) to ensure entries are current. */
725
+ getEntriesByIds(ids) {
726
+ return ids.flatMap(id => {
727
+ const entry = this.entries.get(id);
728
+ return entry ? [entry] : [];
729
+ });
730
+ }
731
+ /** Detect potential conflicts in a result set — lazy, high-signal, never background.
732
+ * Compares all pairs from the given entries using similarity().
733
+ * Only flags pairs where both entries have substantive content (>50 chars) and
734
+ * similarity exceeds 0.6. Returns at most 2 pairs to avoid overwhelming the agent.
735
+ *
736
+ * Accepts a minimal shape so it works with both MemoryEntry and QueryEntry (full detail). */
737
+ detectConflicts(entries) {
738
+ const conflicts = [];
739
+ for (let i = 0; i < entries.length; i++) {
740
+ for (let j = i + 1; j < entries.length; j++) {
741
+ const a = entries[i];
742
+ const b = entries[j];
743
+ // Skip entries with trivially short content — similarity on short text is noisy
744
+ if (a.content.length <= CONFLICT_MIN_CONTENT_CHARS || b.content.length <= CONFLICT_MIN_CONTENT_CHARS)
745
+ continue;
746
+ const contentSim = similarity(a.title, a.content, b.title, b.content);
747
+ const titleSim = similarity(a.title, '', b.title, '');
748
+ // Tiered thresholds: cross-topic gets lower bar (more suspicious when different topics overlap)
749
+ const isSameTopic = a.topic === b.topic;
750
+ let threshold = isSameTopic ? CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC : CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC;
751
+ // Title similarity: if titles overlap significantly, they're about the same thing
752
+ // Lower content threshold since even moderate overlap becomes suspicious
753
+ if (titleSim > 0.30) {
754
+ threshold = Math.min(threshold, 0.38);
755
+ }
756
+ // Trust level boost: two user-corrected entries contradicting is highest-signal
757
+ const bothUserTrust = a.trust === 'user' && b.trust === 'user';
758
+ const trustBoost = bothUserTrust ? 1.3 : 1.0;
759
+ // Opposition word detection: negation indicators suggest prescriptive contradiction
760
+ const NEGATION_INDICATORS = /\b(not|never|avoid|instead of|rather than|deprecated|obsolete|don'?t use)\b/gi;
761
+ const aNegations = (a.content.match(NEGATION_INDICATORS) || []).length;
762
+ const bNegations = (b.content.match(NEGATION_INDICATORS) || []).length;
763
+ const hasNegations = aNegations > 0 && bNegations > 0;
764
+ // Check for explicit opposition pairs in content
765
+ const aLower = a.content.toLowerCase();
766
+ const bLower = b.content.toLowerCase();
767
+ const oppositionMatch = OPPOSITION_PAIRS.some(([term1, term2]) => (aLower.includes(term1) && bLower.includes(term2)) ||
768
+ (aLower.includes(term2) && bLower.includes(term1)));
769
+ // Opposition boost: entries using opposing keywords get stronger conflict signal
770
+ const oppositionBoost = (hasNegations || oppositionMatch) ? 1.25 : 1.0;
771
+ const adjustedScore = contentSim * trustBoost * oppositionBoost;
772
+ if (adjustedScore > threshold) {
773
+ conflicts.push({
774
+ pair: {
775
+ a: { id: a.id, title: a.title, confidence: a.confidence, created: a.created },
776
+ b: { id: b.id, title: b.title, confidence: b.confidence, created: b.created },
777
+ similarity: contentSim, // store raw similarity for display transparency
778
+ },
779
+ score: adjustedScore, // sort by adjusted score
780
+ });
781
+ }
782
+ }
783
+ }
784
+ // Surface highest-similarity conflicts first, cap per behavior config
785
+ return conflicts
786
+ .sort((x, y) => y.score - x.score)
787
+ .slice(0, this.behavior.maxConflictPairs)
788
+ .map(c => c.pair);
789
+ }
790
+ /** Find preferences relevant to a given entry (cross-topic overlap).
791
+ * Lower threshold than dedup since preferences are always worth surfacing. */
792
+ findRelevantPreferences(entry) {
793
+ const relevant = [];
794
+ for (const pref of this.entries.values()) {
795
+ if (pref.topic !== 'preferences')
796
+ continue;
797
+ const sim = similarity(entry.title, entry.content, pref.title, pref.content);
798
+ if (sim > PREFERENCE_SURFACE_THRESHOLD) {
799
+ relevant.push({ entry: pref, similarity: sim });
800
+ }
801
+ }
802
+ return relevant
803
+ .sort((a, b) => b.similarity - a.similarity)
804
+ .slice(0, DEFAULT_MAX_PREFERENCE_SUGGESTIONS)
805
+ .map(r => ({
806
+ id: r.entry.id,
807
+ title: r.entry.title,
808
+ content: r.entry.content,
809
+ confidence: r.entry.confidence,
810
+ trust: r.entry.trust,
811
+ }));
812
+ }
813
+ }