@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Etienne Beaulac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # Memory MCP
2
+
3
+ A Model Context Protocol (MCP) server that gives AI coding agents persistent, evolving knowledge about a codebase. Instead of starting cold every session, agents can store and retrieve observations about architecture, conventions, gotchas, and recent work context.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Description |
8
+ |------|-------------|
9
+ | `memory_context` | Session start AND pre-task lookup. Call with no args for user + preferences + stale nudges; call with `context` for task-specific knowledge |
10
+ | `memory_query` | Structured search with brief/standard/full detail levels and AND/OR/NOT filter syntax. Scope defaults to `"*"` (all topics) |
11
+ | `memory_store` | Store a knowledge entry with dedup detection, preference surfacing, and lobe auto-detection from file paths |
12
+ | `memory_correct` | Correct, update, or delete an existing entry (suggests storing as preference) |
13
+ | `memory_bootstrap` | First-use scan to seed knowledge from repo structure, README, and build files |
14
+
15
+ > **Hidden tools** (still callable, not in the catalog — agents learn about them from hints/errors):
16
+ > `memory_list_lobes` (lobe paths and stats), `memory_stats` (entry counts, freshness, storage), `memory_diagnose` (server health, crash history, recovery steps)
17
+
18
+ ## Knowledge Topics
19
+
20
+ | Topic | Purpose | Global? | Expires? | Default Trust |
21
+ |-------|---------|---------|----------|---------------|
22
+ | `user` | Personal info (name, role, communication style) | Yes | Never | `user` |
23
+ | `preferences` | Corrections, opinions, coding rules | Yes | Never | `user` |
24
+ | `gotchas` | Pitfalls and known issues | No | Never | `user` |
25
+ | `architecture` | System design, patterns, module structure | No | 30 days | `agent-inferred` |
26
+ | `conventions` | Code style, naming, patterns | No | 30 days | `agent-inferred` |
27
+ | `modules/<name>` | Per-module knowledge | No | 30 days | `agent-inferred` |
28
+ | `recent-work` | Current task context (branch-scoped) | No | 30 days | `agent-inferred` |
29
+
30
+ **Global topics** (`user`, `preferences`) are stored in a shared global store at `~/.memory-mcp/global/` and are accessible from all lobes. This means your identity and coding preferences follow you across every repository without duplication.
31
+
32
+ ### Smart Surfacing
33
+
34
+ - **Dedup detection**: When you store an entry, the response shows similar existing entries in the same topic (>35% keyword overlap) with consolidation instructions
35
+ - **Preference surfacing**: Storing a non-preference entry shows relevant preferences that might conflict
36
+ - **Piggyback hints**: `memory_correct` suggests storing corrections as reusable preferences
37
+ - **`memory_context`**: Describe your task in natural language and get ranked results across all topics with topic-based boosting (preferences 1.8x, gotchas 1.5x)
38
+
39
+ ### Smart Filter Syntax
40
+
41
+ `memory_query` supports a filter mini-language for precise searches:
42
+
43
+ | Syntax | Meaning | Example |
44
+ |--------|---------|---------|
45
+ | `A B` | AND (both required) | `reducer sealed` |
46
+ | `A\|B` | OR (either matches) | `MVI\|MVVM` |
47
+ | `-A` | NOT (exclude) | `-deprecated` |
48
+ | combined | Mix freely | `kotlin sealed\|swift protocol -deprecated` |
49
+
50
+ Filters use stemmed matching, so `reducers` matches `reducer` and `exceptions` matches `exception`.
51
+
52
+ ## Quick Start
53
+
54
+ ```bash
55
+ # Install dependencies
56
+ npm install
57
+
58
+ # Build
59
+ npm run build
60
+
61
+ # Run tests
62
+ npm test
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ ### Config File (Recommended)
68
+
69
+ Create a `memory-config.json` file next to the memory MCP server:
70
+
71
+ ```json
72
+ {
73
+ "lobes": {
74
+ "workspace-mcp": {
75
+ "root": "$HOME/git/personal/workspace-mcp",
76
+ "budgetMB": 2
77
+ },
78
+ "workrail": {
79
+ "root": "$HOME/git/personal/workrail",
80
+ "budgetMB": 2
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ > **Note:** `memoryDir` is optional. When omitted, storage auto-detects to `.git/memory/` for git repos.
87
+
88
+ **What's a "lobe"?** Each repository gets its own memory lobe -- a dedicated knowledge scope. Think of it like brain regions: the "workrail lobe" stores knowledge about workrail, the "workspace-mcp lobe" stores knowledge about workspace-mcp.
89
+
90
+ **Benefits:**
91
+ - Portable (`$HOME` and `~` expansion works across machines)
92
+ - Discoverable (use `memory_list_lobes` to see what's configured)
93
+ - Easy to extend (just add a new lobe entry)
94
+
95
+ ### Environment Variables (Fallback)
96
+
97
+ If no `memory-config.json` is found, the server falls back to environment variables:
98
+
99
+ | Variable | Default | Description |
100
+ |----------|---------|-------------|
101
+ | `MEMORY_MCP_WORKSPACES` | -- | JSON mapping workspace names to repo paths (multi-repo mode) |
102
+ | `MEMORY_MCP_REPO_ROOT` | `process.cwd()` | Fallback: single-repo path (if `WORKSPACES` not set) |
103
+ | `MEMORY_MCP_DIR` | *(auto-detect)* | Override storage dir (relative to repo root, or absolute). Disables git-native auto-detection. |
104
+ | `MEMORY_MCP_BUDGET` | `2097152` (2MB) | Storage budget per workspace in bytes |
105
+
106
+ ### Adding a New Lobe
107
+
108
+ 1. **Edit `memory-config.json`** (create if it doesn't exist)
109
+ 2. **Add lobe entry:**
110
+ ```json
111
+ "my-project": {
112
+ "root": "$HOME/git/my-project",
113
+ "budgetMB": 2
114
+ }
115
+ ```
116
+ 3. **Restart the memory MCP server**
117
+ 4. **Verify:** Use `memory_list_lobes` to confirm it loaded
118
+
119
+ The agent will see the new lobe in tool descriptions and can immediately use it with `memory_store(lobe: "my-project", ...)`.
120
+
121
+ ## MCP Client Registration
122
+
123
+ With `memory-config.json` (recommended):
124
+
125
+ ```json
126
+ {
127
+ "mcpServers": {
128
+ "memory": {
129
+ "command": "node",
130
+ "args": ["/path/to/memory-mcp/dist/index.js"]
131
+ }
132
+ }
133
+ }
134
+ ```
135
+
136
+ The server reads `memory-config.json` automatically -- no env vars needed.
137
+
138
+ ### Environment Variable Mode
139
+
140
+ ```json
141
+ {
142
+ "mcpServers": {
143
+ "memory": {
144
+ "command": "node",
145
+ "args": ["/path/to/memory-mcp/dist/index.js"],
146
+ "env": {
147
+ "MEMORY_MCP_WORKSPACES": "{\"android\":\"/path/to/android\",\"ios\":\"/path/to/ios\"}"
148
+ }
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ ## Storage Location
155
+
156
+ Knowledge is stored as human-readable Markdown files -- **one file per entry**. The storage location is **auto-detected** with the following priority:
157
+
158
+ 1. **Explicit `memoryDir` config** -- if set in `memory-config.json` or `MEMORY_MCP_DIR`, uses that path
159
+ 2. **Git-native** (default) -- `<git-common-dir>/memory/` using `git rev-parse --git-common-dir`. This ensures:
160
+ - **Invisible to git** -- `.git/` contents are never tracked, no `.gitignore` needed
161
+ - **Shared across worktrees** -- all worktrees of the same repo share one memory store
162
+ - **Worktree/submodule safe** -- resolves to the common `.git/` directory regardless
163
+ 3. **Central fallback** -- `~/.memory-mcp/<lobe-name>/` for non-git directories
164
+
165
+ Use `memory_stats` or `memory_list_lobes` to see where memory is stored for each lobe.
166
+
167
+ ### File Structure
168
+
169
+ Each entry gets its own file. Recent-work entries are scoped by branch.
170
+
171
+ ```
172
+ .git/memory/
173
+ architecture/
174
+ arch-e8d4f012.md # One entry per file
175
+ conventions/
176
+ conv-a1b2c3d4.md
177
+ gotchas/
178
+ gotcha-7k3m9p2q.md
179
+ recent-work/
180
+ main/ # Branch-scoped
181
+ recent-f5e6d7c8.md
182
+ feature-messaging-refactor/ # Sanitized branch name
183
+ recent-9i0j1k2l.md
184
+ modules/
185
+ messaging/
186
+ mod-4d5e6f7g.md
187
+
188
+ ~/.memory-mcp/global/ # Global store (shared across all lobes)
189
+ user/
190
+ user-3f7a2b1c.md # Personal info
191
+ preferences/
192
+ pref-5c9b7e3d.md # Coding opinions & corrections
193
+ ```
194
+
195
+ ### Concurrency Safety
196
+
197
+ Each entry is its own file with a random hex ID. Two MCP processes (e.g., Firebender + Cursor) writing different entries to the same repo **never conflict** -- they write to different files. The store reloads from disk before every read to pick up changes from other processes.
198
+
199
+ ### Branch-Scoped Recent Work
200
+
201
+ Recent-work entries are automatically tagged with the current git branch and stored in a branch-named subdirectory. `memory_query` filters recent-work to the current branch by default. Use `branch: "*"` to see recent-work from all branches.
202
+
203
+ ### Entry Format
204
+
205
+ ```markdown
206
+ # Build System & Language
207
+ - **id**: arch-3f7a2b1c
208
+ - **topic**: architecture
209
+ - **confidence**: 0.70
210
+ - **trust**: agent-inferred
211
+ - **created**: 2026-02-18T12:00:00.000Z
212
+ - **lastAccessed**: 2026-02-18T12:00:00.000Z
213
+
214
+ Detected: Node.js/TypeScript project (npm)
215
+ ```
216
+
217
+ ## Trust Levels
218
+
219
+ | Level | Confidence | Meaning |
220
+ |-------|-----------|---------|
221
+ | `user` | 1.0 | Human-provided or human-corrected knowledge |
222
+ | `agent-confirmed` | 0.85 | Agent-observed and verified against code |
223
+ | `agent-inferred` | 0.70 | Agent-observed, not yet verified |
224
+
225
+ ## Resilience
226
+
227
+ The server uses a **degradation ladder** to stay useful even when things go wrong:
228
+
229
+ - **Running** -- all lobes healthy, full functionality
230
+ - **Degraded** -- some lobes failed to initialize but healthy ones continue working. Failed lobes report specific recovery steps via `memory_diagnose`.
231
+ - **Safe Mode** -- all lobes failed. Only `memory_diagnose` and `memory_list_lobes` work, giving you enough information to fix the problem.
232
+
233
+ **Crash journaling**: On uncaught exceptions, the server writes a structured crash report to `~/.memory-mcp/crashes/` before exiting. The next startup surfaces the crash in `memory_context()` (briefing mode) with recovery steps. Use `memory_diagnose(showCrashHistory: true)` to see the full history.
234
+
235
+ ### Argument Normalization
236
+
237
+ Agents frequently guess wrong parameter names. The server silently resolves common aliases to avoid wasted round-trips:
238
+
239
+ | Alias | Resolves to |
240
+ |-------|-------------|
241
+ | `key`, `name` | `title` |
242
+ | `value`, `body`, `text` | `content` |
243
+ | `query`, `search` | `filter` |
244
+ | `workspace`, `repo` | `lobe` |
245
+
246
+ Wildcard scope aliases (`all`, `everything`, `global`, `project`) resolve to `*`.
247
+
248
+ ## Architecture
249
+
250
+ ```
251
+ types.ts Domain types (discriminated unions, parse functions)
252
+ store.ts MarkdownMemoryStore (CRUD, search, bootstrap, briefing)
253
+ text-analyzer.ts Keyword extraction, stemming, similarity (stateless)
254
+ normalize.ts Argument alias resolution (pure)
255
+ formatters.ts Response formatters for tool handlers (pure)
256
+ config.ts 3-tier config loading (file > env > default)
257
+ git-service.ts Git operations boundary (injectable for testing)
258
+ crash-journal.ts Crash report lifecycle (build, write, read, format)
259
+ index.ts MCP server, tool handlers, startup, migration
260
+ ```
261
+
262
+ ## Design
263
+
264
+ See `ideas/codebase-memory-mcp-design-thinking.md` for the full design thinking document with 67 ideas, 5 concept packages, pre-mortem analysis, and test plan.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,237 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { promises as fs } from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { MarkdownMemoryStore } from '../store.js';
7
+ import { DEFAULT_STORAGE_BUDGET_BYTES, parseTopicScope, parseTrustLevel } from '../types.js';
8
+ // --- Fake clock: deterministic time for testing staleness ---
9
+ function fakeClock(isoDate) {
10
+ const date = new Date(isoDate);
11
+ return {
12
+ now: () => new Date(date.getTime()),
13
+ isoNow: () => date.toISOString(),
14
+ };
15
+ }
16
+ function makeConfig(repoRoot, clock) {
17
+ return {
18
+ repoRoot,
19
+ memoryPath: path.join(repoRoot, '.memory'),
20
+ storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
21
+ clock,
22
+ };
23
+ }
24
+ async function createTempDir() {
25
+ return await fs.mkdtemp(path.join(os.tmpdir(), 'memory-mcp-clock-test-'));
26
+ }
27
+ // --- parseTopicScope tests ---
28
+ describe('parseTopicScope', () => {
29
+ it('accepts all fixed topics', () => {
30
+ const fixed = ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'];
31
+ for (const topic of fixed) {
32
+ assert.strictEqual(parseTopicScope(topic), topic, `Should accept "${topic}"`);
33
+ }
34
+ });
35
+ it('accepts modules/ prefixed topics', () => {
36
+ assert.strictEqual(parseTopicScope('modules/messaging'), 'modules/messaging');
37
+ assert.strictEqual(parseTopicScope('modules/auth'), 'modules/auth');
38
+ assert.strictEqual(parseTopicScope('modules/deep/nested'), 'modules/deep/nested');
39
+ });
40
+ it('rejects empty string', () => {
41
+ assert.strictEqual(parseTopicScope(''), null);
42
+ });
43
+ it('rejects arbitrary strings', () => {
44
+ assert.strictEqual(parseTopicScope('banana'), null);
45
+ assert.strictEqual(parseTopicScope('foobar'), null);
46
+ assert.strictEqual(parseTopicScope('arch'), null);
47
+ });
48
+ it('rejects "modules/" without a name', () => {
49
+ assert.strictEqual(parseTopicScope('modules/'), null);
50
+ });
51
+ it('is case-sensitive', () => {
52
+ assert.strictEqual(parseTopicScope('Architecture'), null);
53
+ assert.strictEqual(parseTopicScope('USER'), null);
54
+ });
55
+ });
56
+ // --- parseTrustLevel tests ---
57
+ describe('parseTrustLevel', () => {
58
+ it('accepts all valid trust levels', () => {
59
+ assert.strictEqual(parseTrustLevel('user'), 'user');
60
+ assert.strictEqual(parseTrustLevel('agent-confirmed'), 'agent-confirmed');
61
+ assert.strictEqual(parseTrustLevel('agent-inferred'), 'agent-inferred');
62
+ });
63
+ it('rejects arbitrary strings', () => {
64
+ assert.strictEqual(parseTrustLevel('admin'), null);
65
+ assert.strictEqual(parseTrustLevel(''), null);
66
+ assert.strictEqual(parseTrustLevel('USER'), null);
67
+ });
68
+ });
69
+ // --- Fake clock: staleness and freshness ---
70
+ describe('Clock injection for freshness', () => {
71
+ let tempDir;
72
+ beforeEach(async () => {
73
+ tempDir = await createTempDir();
74
+ });
75
+ it('entries older than 30 days are stale with fake clock', async () => {
76
+ // Store entries with a clock set to Jan 1, 2025
77
+ const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
78
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
79
+ await store1.init();
80
+ await store1.store('architecture', 'Old Pattern', 'This was stored long ago', [], 'agent-inferred');
81
+ // Query with a clock set to Mar 1, 2025 (59 days later — well past 30)
82
+ const mar1 = fakeClock('2025-03-01T00:00:00.000Z');
83
+ const store2 = new MarkdownMemoryStore(makeConfig(tempDir, mar1));
84
+ await store2.init();
85
+ const result = await store2.query('architecture', 'brief');
86
+ assert.strictEqual(result.entries.length, 1);
87
+ assert.strictEqual(result.entries[0].fresh, false, 'Entry should be stale after 59 days');
88
+ });
89
+ it('entries within 30 days are fresh with fake clock', async () => {
90
+ const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
91
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
92
+ await store1.init();
93
+ await store1.store('architecture', 'Recent Pattern', 'Just stored', [], 'agent-inferred');
94
+ // Query 15 days later — still within 30
95
+ const jan16 = fakeClock('2025-01-16T00:00:00.000Z');
96
+ const store2 = new MarkdownMemoryStore(makeConfig(tempDir, jan16));
97
+ await store2.init();
98
+ const result = await store2.query('architecture', 'brief');
99
+ assert.strictEqual(result.entries.length, 1);
100
+ assert.strictEqual(result.entries[0].fresh, true, 'Entry should be fresh within 30 days');
101
+ });
102
+ it('user topic entries are always fresh regardless of age', async () => {
103
+ const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
104
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
105
+ await store1.init();
106
+ await store1.store('user', 'Identity', 'An engineer', [], 'user');
107
+ // Query 1 year later — user topic is exempt
108
+ const nextYear = fakeClock('2026-01-01T00:00:00.000Z');
109
+ const store2 = new MarkdownMemoryStore(makeConfig(tempDir, nextYear));
110
+ await store2.init();
111
+ const result = await store2.query('user', 'brief');
112
+ assert.strictEqual(result.entries[0].fresh, true, 'User topic should always be fresh');
113
+ });
114
+ it('preferences entries are stale after 90 days, fresh before', async () => {
115
+ // Use separate temp dirs to prevent query() from refreshing lastAccessed between scenarios
116
+ const dir60 = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-pref-60-'));
117
+ const dir100 = await fs.mkdtemp(path.join(os.tmpdir(), 'mem-pref-100-'));
118
+ try {
119
+ const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
120
+ // Scenario 1: written Jan 1, queried Mar 2 (60 days) — should be fresh
121
+ const write60 = new MarkdownMemoryStore(makeConfig(dir60, jan1));
122
+ await write60.init();
123
+ await write60.store('preferences', 'Style', 'Functional first', [], 'user');
124
+ const mar2 = fakeClock('2025-03-02T00:00:00.000Z'); // ~60 days later
125
+ const read60 = new MarkdownMemoryStore(makeConfig(dir60, mar2));
126
+ await read60.init();
127
+ const result60 = await read60.query('preferences', 'brief');
128
+ assert.strictEqual(result60.entries[0].fresh, true, 'Preference at 60 days should still be fresh (within 90-day window)');
129
+ // Scenario 2: written Jan 1, queried Apr 11 (100 days) — should be stale
130
+ const write100 = new MarkdownMemoryStore(makeConfig(dir100, jan1));
131
+ await write100.init();
132
+ await write100.store('preferences', 'Style', 'Functional first', [], 'user');
133
+ const apr11 = fakeClock('2025-04-11T00:00:00.000Z'); // ~100 days later
134
+ const read100 = new MarkdownMemoryStore(makeConfig(dir100, apr11));
135
+ await read100.init();
136
+ const result100 = await read100.query('preferences', 'brief');
137
+ assert.strictEqual(result100.entries[0].fresh, false, 'Preference at 100 days should be stale (exceeds 90-day window)');
138
+ }
139
+ finally {
140
+ await fs.rm(dir60, { recursive: true, force: true }).catch(() => { });
141
+ await fs.rm(dir100, { recursive: true, force: true }).catch(() => { });
142
+ }
143
+ });
144
+ it('gotchas go stale after 30 days (not always-fresh)', async () => {
145
+ const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
146
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
147
+ await store1.init();
148
+ await store1.store('gotchas', 'Build Gotcha', 'Always clean build', [], 'agent-inferred');
149
+ // Query 1 year later — gotchas are NOT exempt from staleness
150
+ const nextYear = fakeClock('2026-01-01T00:00:00.000Z');
151
+ const store2 = new MarkdownMemoryStore(makeConfig(tempDir, nextYear));
152
+ await store2.init();
153
+ const result = await store2.query('gotchas', 'brief');
154
+ assert.strictEqual(result.entries[0].fresh, false, 'Gotchas should go stale after 30 days — code changes make them the most dangerous when outdated');
155
+ });
156
+ it('user-trusted entries in non-user topics go stale normally (trust != temporal validity)', async () => {
157
+ const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
158
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
159
+ await store1.init();
160
+ await store1.store('conventions', 'User Convention', 'User confirmed this', [], 'user');
161
+ // Query 1 year later — trust level does not grant freshness exemption
162
+ const nextYear = fakeClock('2026-01-01T00:00:00.000Z');
163
+ const store2 = new MarkdownMemoryStore(makeConfig(tempDir, nextYear));
164
+ await store2.init();
165
+ const result = await store2.query('conventions', 'brief');
166
+ assert.strictEqual(result.entries[0].fresh, false, 'Trust level does not grant freshness exemption — a user-confirmed entry can still be outdated');
167
+ });
168
+ it('stale count in briefing reflects tiered thresholds', async () => {
169
+ const jan1 = fakeClock('2025-01-01T00:00:00.000Z');
170
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir, jan1));
171
+ await store1.init();
172
+ await store1.store('architecture', 'Old Arch', 'content', [], 'agent-inferred');
173
+ await store1.store('conventions', 'Old Conv', 'content', [], 'agent-confirmed');
174
+ await store1.store('gotchas', 'Gotcha', 'content', [], 'agent-inferred'); // stale after 30 days
175
+ // Briefing 60 days later
176
+ const mar2 = fakeClock('2025-03-02T00:00:00.000Z');
177
+ const store2 = new MarkdownMemoryStore(makeConfig(tempDir, mar2));
178
+ await store2.init();
179
+ const briefing = await store2.briefing(2000);
180
+ // arch + conv + gotcha are all stale at 60 days (all use 30-day threshold)
181
+ assert.strictEqual(briefing.staleEntries, 3, 'arch, conv, and gotcha should all be stale at 60 days');
182
+ });
183
+ it('clock is used for created/lastAccessed timestamps', async () => {
184
+ const fixedTime = fakeClock('2025-06-15T12:30:00.000Z');
185
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir, fixedTime));
186
+ await store1.init();
187
+ await store1.store('architecture', 'Test Entry', 'content');
188
+ const result = await store1.query('architecture', 'full');
189
+ assert.strictEqual(result.entries[0].created, '2025-06-15T12:30:00.000Z');
190
+ assert.strictEqual(result.entries[0].lastAccessed, '2025-06-15T12:30:00.000Z');
191
+ });
192
+ });
193
+ // --- Confidence range validation ---
194
+ describe('confidence clamping on disk read', () => {
195
+ let tempDir;
196
+ beforeEach(async () => {
197
+ tempDir = await createTempDir();
198
+ });
199
+ it('clamps out-of-range confidence to valid range on reload', async () => {
200
+ const store1 = new MarkdownMemoryStore(makeConfig(tempDir));
201
+ await store1.init();
202
+ // Manually write a file with confidence: 999
203
+ const dir = path.join(tempDir, '.memory', 'architecture');
204
+ await fs.mkdir(dir, { recursive: true });
205
+ await fs.writeFile(path.join(dir, 'arch-bad1.md'), [
206
+ '# High Confidence',
207
+ '- **id**: arch-bad1',
208
+ '- **topic**: architecture',
209
+ '- **confidence**: 999',
210
+ '- **trust**: agent-inferred',
211
+ '- **created**: 2025-01-01T00:00:00.000Z',
212
+ '- **lastAccessed**: 2025-01-01T00:00:00.000Z',
213
+ '',
214
+ 'This entry has absurd confidence.',
215
+ ].join('\n'));
216
+ // Manually write a file with confidence: -5
217
+ await fs.writeFile(path.join(dir, 'arch-bad2.md'), [
218
+ '# Negative Confidence',
219
+ '- **id**: arch-bad2',
220
+ '- **topic**: architecture',
221
+ '- **confidence**: -5',
222
+ '- **trust**: agent-inferred',
223
+ '- **created**: 2025-01-01T00:00:00.000Z',
224
+ '- **lastAccessed**: 2025-01-01T00:00:00.000Z',
225
+ '',
226
+ 'This entry has negative confidence.',
227
+ ].join('\n'));
228
+ const store2 = new MarkdownMemoryStore(makeConfig(tempDir));
229
+ await store2.init();
230
+ const result = await store2.query('architecture', 'full');
231
+ assert.strictEqual(result.entries.length, 2);
232
+ for (const entry of result.entries) {
233
+ assert.ok(entry.confidence >= 0.0, `Confidence ${entry.confidence} should be >= 0.0`);
234
+ assert.ok(entry.confidence <= 1.0, `Confidence ${entry.confidence} should be <= 1.0`);
235
+ }
236
+ });
237
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { ConfigManager } from '../config-manager.js';
4
+ // Test helper: ConfigManager subclass with injectable stat function
5
+ class TestableConfigManager extends ConfigManager {
6
+ constructor() {
7
+ super(...arguments);
8
+ this.statImplementation = async () => {
9
+ return { mtimeMs: Date.now() - 1000 };
10
+ };
11
+ }
12
+ statFile(path) {
13
+ return this.statImplementation(path);
14
+ }
15
+ }
16
+ describe('ConfigManager', () => {
17
+ let initialConfig;
18
+ let initialStores;
19
+ let initialHealth;
20
+ let configPath;
21
+ beforeEach(() => {
22
+ configPath = '/fake/memory-config.json';
23
+ // Mock initial config
24
+ const mockConfig = {
25
+ repoRoot: '/fake/repo',
26
+ memoryPath: '/fake/memory',
27
+ storageBudgetBytes: 2 * 1024 * 1024,
28
+ };
29
+ initialConfig = {
30
+ configs: new Map([['test-lobe', mockConfig]]),
31
+ origin: { source: 'file', path: configPath },
32
+ };
33
+ // Mock initial stores
34
+ const mockStore = {};
35
+ initialStores = new Map([['test-lobe', mockStore]]);
36
+ initialHealth = new Map([['test-lobe', { status: 'healthy' }]]);
37
+ });
38
+ describe('ensureFresh', () => {
39
+ it('does not reload when mtime unchanged', async () => {
40
+ const manager = new TestableConfigManager(configPath, initialConfig, initialStores, initialHealth);
41
+ let statCallCount = 0;
42
+ manager.statImplementation = async () => {
43
+ statCallCount++;
44
+ return { mtimeMs: Date.now() - 1000 }; // mtime in the past (no change)
45
+ };
46
+ await manager.ensureFresh();
47
+ await manager.ensureFresh();
48
+ // Stat should be called, but reload should not happen
49
+ assert.equal(statCallCount, 2, 'stat should be called twice');
50
+ assert.equal(manager.getLobeNames().length, 1, 'Should still have 1 lobe');
51
+ });
52
+ it('gracefully handles stat ENOENT error (file deleted)', async () => {
53
+ const manager = new TestableConfigManager(configPath, initialConfig, initialStores, initialHealth);
54
+ manager.statImplementation = async () => {
55
+ const error = new Error('ENOENT: no such file');
56
+ error.code = 'ENOENT';
57
+ throw error;
58
+ };
59
+ // Should not throw, should keep old config
60
+ await manager.ensureFresh();
61
+ assert.equal(manager.getLobeNames().length, 1, 'Should keep old config on ENOENT');
62
+ });
63
+ it('gracefully handles stat EACCES error (permission denied)', async () => {
64
+ const manager = new TestableConfigManager(configPath, initialConfig, initialStores, initialHealth);
65
+ manager.statImplementation = async () => {
66
+ const error = new Error('EACCES: permission denied');
67
+ error.code = 'EACCES';
68
+ throw error;
69
+ };
70
+ // Should not throw, should keep old config
71
+ await manager.ensureFresh();
72
+ assert.equal(manager.getLobeNames().length, 1, 'Should keep old config on EACCES');
73
+ });
74
+ it('skips reload for env-var-based configs', async () => {
75
+ const envConfig = {
76
+ configs: new Map([['test-lobe', initialConfig.configs.get('test-lobe')]]),
77
+ origin: { source: 'env' },
78
+ };
79
+ let statCalled = false;
80
+ const manager = new TestableConfigManager(configPath, envConfig, initialStores, initialHealth);
81
+ manager.statImplementation = async () => {
82
+ statCalled = true;
83
+ return { mtimeMs: Date.now() };
84
+ };
85
+ await manager.ensureFresh();
86
+ // stat should NOT be called for env-based configs
87
+ assert.equal(statCalled, false, 'Should not stat for env-based configs');
88
+ });
89
+ it('skips reload for default-based configs', async () => {
90
+ const defaultConfig = {
91
+ configs: new Map([['default', initialConfig.configs.get('test-lobe')]]),
92
+ origin: { source: 'default' },
93
+ };
94
+ let statCalled = false;
95
+ const manager = new TestableConfigManager(configPath, defaultConfig, initialStores, initialHealth);
96
+ manager.statImplementation = async () => {
97
+ statCalled = true;
98
+ return { mtimeMs: Date.now() };
99
+ };
100
+ await manager.ensureFresh();
101
+ // stat should NOT be called for default configs
102
+ assert.equal(statCalled, false, 'Should not stat for default configs');
103
+ });
104
+ });
105
+ describe('accessors', () => {
106
+ it('getStore returns store for existing lobe', () => {
107
+ const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
108
+ const store = manager.getStore('test-lobe');
109
+ assert.ok(store !== undefined, 'Should return store for existing lobe');
110
+ });
111
+ it('getStore returns undefined for non-existent lobe', () => {
112
+ const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
113
+ const store = manager.getStore('nonexistent');
114
+ assert.equal(store, undefined, 'Should return undefined for missing lobe');
115
+ });
116
+ it('getLobeNames returns array of lobe names', () => {
117
+ const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
118
+ const names = manager.getLobeNames();
119
+ assert.deepEqual(names, ['test-lobe']);
120
+ });
121
+ it('getConfigOrigin returns current origin', () => {
122
+ const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
123
+ const origin = manager.getConfigOrigin();
124
+ assert.equal(origin.source, 'file');
125
+ if (origin.source === 'file') {
126
+ assert.equal(origin.path, configPath);
127
+ }
128
+ });
129
+ it('getLobeHealth returns health for existing lobe', () => {
130
+ const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
131
+ const health = manager.getLobeHealth('test-lobe');
132
+ assert.ok(health !== undefined);
133
+ assert.equal(health.status, 'healthy');
134
+ });
135
+ it('getLobeConfig returns config for existing lobe', () => {
136
+ const manager = new ConfigManager(configPath, initialConfig, initialStores, initialHealth);
137
+ const config = manager.getLobeConfig('test-lobe');
138
+ assert.ok(config !== undefined);
139
+ assert.equal(config.repoRoot, '/fake/repo');
140
+ });
141
+ });
142
+ });
@@ -0,0 +1 @@
1
+ export {};