@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.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/__tests__/clock-and-validators.test.d.ts +1 -0
- package/dist/__tests__/clock-and-validators.test.js +237 -0
- package/dist/__tests__/config-manager.test.d.ts +1 -0
- package/dist/__tests__/config-manager.test.js +142 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +236 -0
- package/dist/__tests__/crash-journal.test.d.ts +1 -0
- package/dist/__tests__/crash-journal.test.js +203 -0
- package/dist/__tests__/e2e.test.d.ts +1 -0
- package/dist/__tests__/e2e.test.js +788 -0
- package/dist/__tests__/ephemeral-benchmark.test.d.ts +1 -0
- package/dist/__tests__/ephemeral-benchmark.test.js +651 -0
- package/dist/__tests__/ephemeral.test.d.ts +1 -0
- package/dist/__tests__/ephemeral.test.js +435 -0
- package/dist/__tests__/git-service.test.d.ts +1 -0
- package/dist/__tests__/git-service.test.js +43 -0
- package/dist/__tests__/normalize.test.d.ts +1 -0
- package/dist/__tests__/normalize.test.js +161 -0
- package/dist/__tests__/store.test.d.ts +1 -0
- package/dist/__tests__/store.test.js +1153 -0
- package/dist/config-manager.d.ts +49 -0
- package/dist/config-manager.js +126 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +162 -0
- package/dist/crash-journal.d.ts +38 -0
- package/dist/crash-journal.js +198 -0
- package/dist/ephemeral-weights.json +1847 -0
- package/dist/ephemeral.d.ts +20 -0
- package/dist/ephemeral.js +516 -0
- package/dist/formatters.d.ts +10 -0
- package/dist/formatters.js +92 -0
- package/dist/git-service.d.ts +5 -0
- package/dist/git-service.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1197 -0
- package/dist/normalize.d.ts +2 -0
- package/dist/normalize.js +69 -0
- package/dist/store.d.ts +84 -0
- package/dist/store.js +813 -0
- package/dist/text-analyzer.d.ts +32 -0
- package/dist/text-analyzer.js +190 -0
- package/dist/thresholds.d.ts +39 -0
- package/dist/thresholds.js +75 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.js +33 -0
- 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 {};
|