@moxxy/plugin-memory 0.27.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/dist/consolidate.d.ts +62 -0
  3. package/dist/consolidate.d.ts.map +1 -0
  4. package/dist/consolidate.js +417 -0
  5. package/dist/consolidate.js.map +1 -0
  6. package/dist/embedding-cache.d.ts +32 -0
  7. package/dist/embedding-cache.d.ts.map +1 -0
  8. package/dist/embedding-cache.js +146 -0
  9. package/dist/embedding-cache.js.map +1 -0
  10. package/dist/index.d.ts +31 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +217 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/parse.d.ts +5 -0
  15. package/dist/parse.d.ts.map +1 -0
  16. package/dist/parse.js +9 -0
  17. package/dist/parse.js.map +1 -0
  18. package/dist/stm.d.ts +20 -0
  19. package/dist/stm.d.ts.map +1 -0
  20. package/dist/stm.js +38 -0
  21. package/dist/stm.js.map +1 -0
  22. package/dist/store/io.d.ts +13 -0
  23. package/dist/store/io.d.ts.map +1 -0
  24. package/dist/store/io.js +124 -0
  25. package/dist/store/io.js.map +1 -0
  26. package/dist/store/search.d.ts +10 -0
  27. package/dist/store/search.d.ts.map +1 -0
  28. package/dist/store/search.js +160 -0
  29. package/dist/store/search.js.map +1 -0
  30. package/dist/store/types.d.ts +33 -0
  31. package/dist/store/types.d.ts.map +1 -0
  32. package/dist/store/types.js +11 -0
  33. package/dist/store/types.js.map +1 -0
  34. package/dist/store.d.ts +95 -0
  35. package/dist/store.d.ts.map +1 -0
  36. package/dist/store.js +220 -0
  37. package/dist/store.js.map +1 -0
  38. package/dist/tfidf.d.ts +29 -0
  39. package/dist/tfidf.d.ts.map +1 -0
  40. package/dist/tfidf.js +103 -0
  41. package/dist/tfidf.js.map +1 -0
  42. package/package.json +62 -0
  43. package/src/consolidate-nudge.test.ts +98 -0
  44. package/src/consolidate.test.ts +328 -0
  45. package/src/consolidate.ts +535 -0
  46. package/src/discovery.test.ts +33 -0
  47. package/src/embedding-cache.test.ts +268 -0
  48. package/src/embedding-cache.ts +156 -0
  49. package/src/index.test.ts +67 -0
  50. package/src/index.ts +247 -0
  51. package/src/parse.ts +12 -0
  52. package/src/stm.test.ts +69 -0
  53. package/src/stm.ts +48 -0
  54. package/src/store/io.test.ts +100 -0
  55. package/src/store/io.ts +138 -0
  56. package/src/store/search.test.ts +185 -0
  57. package/src/store/search.ts +197 -0
  58. package/src/store/types.ts +23 -0
  59. package/src/store.test.ts +186 -0
  60. package/src/store.ts +292 -0
  61. package/src/tfidf.test.ts +59 -0
  62. package/src/tfidf.ts +105 -0
  63. package/src/vector-recall.test.ts +88 -0
@@ -0,0 +1,268 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { EmbeddingProvider } from '@moxxy/sdk';
6
+ import { EmbeddingIndex } from './embedding-cache.js';
7
+ import { MemoryStore } from './store.js';
8
+
9
+ let tmp: string;
10
+ beforeEach(async () => {
11
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-cache-'));
12
+ });
13
+ afterEach(async () => {
14
+ await fs.rm(tmp, { recursive: true, force: true });
15
+ });
16
+
17
+ describe('EmbeddingIndex', () => {
18
+ it('returns null on lookup before load()', () => {
19
+ const idx = new EmbeddingIndex(tmp, 'test');
20
+ expect(idx.lookup('foo', 'body')).toBeNull();
21
+ });
22
+
23
+ it('round-trips a vector through flush+load', async () => {
24
+ const a = new EmbeddingIndex(tmp, 'test');
25
+ a.set('foo', 'body text', [0.1, 0.2, 0.3]);
26
+ await a.flush();
27
+
28
+ const b = new EmbeddingIndex(tmp, 'test');
29
+ await b.load();
30
+ expect(b.lookup('foo', 'body text')).toEqual([0.1, 0.2, 0.3]);
31
+ });
32
+
33
+ it('returns null when body hash changes (stale entry)', async () => {
34
+ const a = new EmbeddingIndex(tmp, 'test');
35
+ a.set('foo', 'original', [1, 2, 3]);
36
+ await a.flush();
37
+
38
+ const b = new EmbeddingIndex(tmp, 'test');
39
+ await b.load();
40
+ expect(b.lookup('foo', 'changed body')).toBeNull();
41
+ });
42
+
43
+ it('invalidates cache when embedder name changes', async () => {
44
+ const a = new EmbeddingIndex(tmp, 'openai');
45
+ a.set('foo', 'body', [1, 2, 3]);
46
+ await a.flush();
47
+
48
+ const b = new EmbeddingIndex(tmp, 'transformers');
49
+ await b.load();
50
+ expect(b.lookup('foo', 'body')).toBeNull();
51
+ });
52
+
53
+ it('invalidates cache when the dim changes under the same embedder name', async () => {
54
+ // Same name (e.g. the OpenAI embedder's coarse name), different dim — a
55
+ // model switch (1536 → 3072) must NOT reuse incomparable vectors.
56
+ const a = new EmbeddingIndex(tmp, 'openai', 1536);
57
+ a.set('foo', 'body', [1, 2, 3]);
58
+ await a.flush();
59
+
60
+ const b = new EmbeddingIndex(tmp, 'openai', 3072);
61
+ await b.load();
62
+ expect(b.lookup('foo', 'body')).toBeNull();
63
+
64
+ // Same name AND dim → cache is reused.
65
+ const c = new EmbeddingIndex(tmp, 'openai', 1536);
66
+ await c.load();
67
+ expect(c.lookup('foo', 'body')).toEqual([1, 2, 3]);
68
+ });
69
+
70
+ it('prune() removes entries not in the current set', async () => {
71
+ const idx = new EmbeddingIndex(tmp, 'test');
72
+ idx.set('keep', 'b1', [1]);
73
+ idx.set('drop', 'b2', [2]);
74
+ idx.prune(['keep']);
75
+ await idx.flush();
76
+
77
+ const reloaded = new EmbeddingIndex(tmp, 'test');
78
+ await reloaded.load();
79
+ expect(reloaded.lookup('keep', 'b1')).toEqual([1]);
80
+ expect(reloaded.lookup('drop', 'b2')).toBeNull();
81
+ });
82
+
83
+ it('flush() is a no-op when nothing changed', async () => {
84
+ const idx = new EmbeddingIndex(tmp, 'test');
85
+ await idx.load(); // no file yet
86
+ await idx.flush();
87
+ await expect(fs.access(path.join(tmp, '.embeddings.json'))).rejects.toThrow();
88
+ });
89
+
90
+ it('reads the cache file at most once across repeated load() calls', async () => {
91
+ const seed = new EmbeddingIndex(tmp, 'test');
92
+ seed.set('foo', 'body', [1, 2, 3]);
93
+ await seed.flush();
94
+
95
+ const idx = new EmbeddingIndex(tmp, 'test');
96
+ const readSpy = vi.spyOn(fs, 'readFile');
97
+ await idx.load();
98
+ await idx.load();
99
+ await idx.load();
100
+ expect(readSpy.mock.calls.length).toBe(1);
101
+ // The in-memory cache is still consulted after the single read.
102
+ expect(idx.lookup('foo', 'body')).toEqual([1, 2, 3]);
103
+ readSpy.mockRestore();
104
+ });
105
+
106
+ it('does not re-read after an initial miss (no file on disk)', async () => {
107
+ const idx = new EmbeddingIndex(tmp, 'test');
108
+ const readSpy = vi.spyOn(fs, 'readFile');
109
+ await idx.load(); // ENOENT
110
+ await idx.load();
111
+ expect(readSpy.mock.calls.length).toBe(1);
112
+ readSpy.mockRestore();
113
+ });
114
+
115
+ it('degrades a corrupt (unparseable) cache file to a cold cache instead of throwing', async () => {
116
+ await fs.writeFile(path.join(tmp, '.embeddings.json'), '{ this is not valid json ', 'utf8');
117
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
118
+ const idx = new EmbeddingIndex(tmp, 'test');
119
+ try {
120
+ await expect(idx.load()).resolves.toBeUndefined(); // never throws
121
+ expect(idx.lookup('anything', 'body')).toBeNull(); // cold cache
122
+ expect(warn).toHaveBeenCalled();
123
+ } finally {
124
+ warn.mockRestore();
125
+ }
126
+ });
127
+
128
+ it('degrades a structurally-valid-but-shapeless cache file (no entries) to a cold cache', async () => {
129
+ // {version,embedder} with no `entries` previously threw a TypeError at
130
+ // Object.entries(undefined), permanently breaking every recall.
131
+ await fs.writeFile(
132
+ path.join(tmp, '.embeddings.json'),
133
+ JSON.stringify({ version: 1, embedder: 'test' }),
134
+ 'utf8',
135
+ );
136
+ const idx = new EmbeddingIndex(tmp, 'test');
137
+ await expect(idx.load()).resolves.toBeUndefined();
138
+ expect(idx.lookup('foo', 'body')).toBeNull();
139
+ });
140
+
141
+ it('skips malformed entries but keeps the well-formed ones', async () => {
142
+ await fs.writeFile(
143
+ path.join(tmp, '.embeddings.json'),
144
+ JSON.stringify({
145
+ version: 1,
146
+ embedder: 'test',
147
+ entries: {
148
+ good: { hash: EmbeddingIndex.hash('body'), vector: [1, 2, 3] },
149
+ bad: { hash: 42, vector: 'not-an-array' },
150
+ alsoBad: { vector: [1, 2] }, // missing hash
151
+ },
152
+ }),
153
+ 'utf8',
154
+ );
155
+ const idx = new EmbeddingIndex(tmp, 'test');
156
+ await idx.load();
157
+ expect(idx.lookup('good', 'body')).toEqual([1, 2, 3]);
158
+ expect(idx.lookup('bad', 'body')).toBeNull();
159
+ expect(idx.lookup('alsoBad', 'body')).toBeNull();
160
+ });
161
+ });
162
+
163
+ describe('MemoryStore vector recall with persistent index', () => {
164
+ const makeCountingEmbedder = (): EmbeddingProvider & { calls: number; embed: ReturnType<typeof vi.fn> } => {
165
+ const state = { calls: 0 };
166
+ const embed = vi.fn(async (texts: ReadonlyArray<string>) => {
167
+ state.calls += texts.length;
168
+ // Deterministic per-text vector: hash → 3 small floats
169
+ return texts.map((t) => {
170
+ const code = t.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
171
+ return [code / 1000, (code * 7) % 1, (code * 13) % 1];
172
+ });
173
+ });
174
+ return Object.assign(
175
+ { name: 'counting', dim: 3 as const, embed },
176
+ {
177
+ get calls() {
178
+ return state.calls;
179
+ },
180
+ },
181
+ );
182
+ };
183
+
184
+ it('first recall embeds the full corpus + query', async () => {
185
+ const embedder = makeCountingEmbedder();
186
+ const store = new MemoryStore({ dir: tmp, embedder });
187
+ await store.save({ name: 'a', type: 'fact', description: 'A', body: 'body A' });
188
+ await store.save({ name: 'b', type: 'fact', description: 'B', body: 'body B' });
189
+ embedder.embed.mockClear();
190
+ await store.recall('q');
191
+ // 2 entries + 1 query
192
+ const totalEmbeds = embedder.embed.mock.calls.reduce(
193
+ (sum, call) => sum + (call[0] as string[]).length,
194
+ 0,
195
+ );
196
+ expect(totalEmbeds).toBe(3);
197
+ });
198
+
199
+ it('second recall with unchanged corpus only embeds the new query', async () => {
200
+ const embedder = makeCountingEmbedder();
201
+ const store = new MemoryStore({ dir: tmp, embedder });
202
+ await store.save({ name: 'a', type: 'fact', description: 'A', body: 'body A' });
203
+ await store.save({ name: 'b', type: 'fact', description: 'B', body: 'body B' });
204
+
205
+ await store.recall('first query'); // populates cache
206
+ embedder.embed.mockClear();
207
+ await store.recall('second query'); // should only re-embed the new query
208
+
209
+ const totalEmbeds = embedder.embed.mock.calls.reduce(
210
+ (sum, call) => sum + (call[0] as string[]).length,
211
+ 0,
212
+ );
213
+ expect(totalEmbeds).toBe(1);
214
+ });
215
+
216
+ it('changing one entry re-embeds only that one + query', async () => {
217
+ const embedder = makeCountingEmbedder();
218
+ const store = new MemoryStore({ dir: tmp, embedder });
219
+ await store.save({ name: 'a', type: 'fact', description: 'A', body: 'body A' });
220
+ await store.save({ name: 'b', type: 'fact', description: 'B', body: 'body B' });
221
+
222
+ await store.recall('q');
223
+ embedder.embed.mockClear();
224
+ await store.update('a', { body: 'CHANGED body A' });
225
+ await store.recall('q');
226
+
227
+ const totalEmbeds = embedder.embed.mock.calls.reduce(
228
+ (sum, call) => sum + (call[0] as string[]).length,
229
+ 0,
230
+ );
231
+ // 'a' changed → re-embed; 'b' unchanged → cached; query → fresh. Total = 2.
232
+ expect(totalEmbeds).toBe(2);
233
+ });
234
+
235
+ it('persists across MemoryStore instances (cache survives process restart)', async () => {
236
+ const e1 = makeCountingEmbedder();
237
+ const s1 = new MemoryStore({ dir: tmp, embedder: e1 });
238
+ await s1.save({ name: 'a', type: 'fact', description: 'A', body: 'body A' });
239
+ await s1.recall('q');
240
+
241
+ const e2 = makeCountingEmbedder();
242
+ const s2 = new MemoryStore({ dir: tmp, embedder: e2 });
243
+ e2.embed.mockClear();
244
+ await s2.recall('q2');
245
+
246
+ // s2's cache loads from disk → only embeds the new query
247
+ const totalEmbeds = e2.embed.mock.calls.reduce(
248
+ (sum, call) => sum + (call[0] as string[]).length,
249
+ 0,
250
+ );
251
+ expect(totalEmbeds).toBe(1);
252
+ });
253
+
254
+ it('persistEmbeddings: false disables caching even for neural embedders', async () => {
255
+ const embedder = makeCountingEmbedder();
256
+ const store = new MemoryStore({ dir: tmp, embedder, persistEmbeddings: false });
257
+ await store.save({ name: 'a', type: 'fact', description: 'A', body: 'body A' });
258
+ await store.recall('q');
259
+ embedder.embed.mockClear();
260
+ await store.recall('q');
261
+ // No cache → re-embeds everything
262
+ const totalEmbeds = embedder.embed.mock.calls.reduce(
263
+ (sum, call) => sum + (call[0] as string[]).length,
264
+ 0,
265
+ );
266
+ expect(totalEmbeds).toBe(2); // 1 entry + 1 query
267
+ });
268
+ });
@@ -0,0 +1,156 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { writeFileAtomic } from '@moxxy/sdk/server';
5
+
6
+ const INDEX_FILE = '.embeddings.json';
7
+ const INDEX_VERSION = 1;
8
+
9
+ interface IndexFile {
10
+ readonly version: typeof INDEX_VERSION;
11
+ readonly embedder: string;
12
+ /** Vector dimensionality the cache was built with (undefined = pre-dim format). */
13
+ readonly dim?: number | 'dynamic';
14
+ readonly entries: Record<string, IndexEntry>;
15
+ }
16
+
17
+ interface IndexEntry {
18
+ readonly hash: string;
19
+ readonly vector: ReadonlyArray<number>;
20
+ }
21
+
22
+ /**
23
+ * Persists computed embeddings to `<memoryDir>/.embeddings.json` keyed by
24
+ * content hash. The cache is invalidated when the embedder name OR its
25
+ * dimensionality changes — a name alone is too coarse (e.g. the OpenAI embedder
26
+ * reports a fixed name across models/`dimensions` settings, so a 1536→3072
27
+ * model switch must invalidate on the dim mismatch or recall compares
28
+ * incomparable vectors).
29
+ */
30
+ export class EmbeddingIndex {
31
+ private cache: Map<string, IndexEntry> = new Map();
32
+ private dirty = false;
33
+ private loaded = false;
34
+
35
+ constructor(
36
+ private readonly dir: string,
37
+ private readonly embedderName: string,
38
+ private readonly dim?: number | 'dynamic',
39
+ ) {}
40
+
41
+ static hash(text: string): string {
42
+ return createHash('sha256').update(text).digest('hex').slice(0, 24);
43
+ }
44
+
45
+ private get filePath(): string {
46
+ return path.join(this.dir, INDEX_FILE);
47
+ }
48
+
49
+ async load(): Promise<void> {
50
+ // Read the on-disk cache at most once per instance. The store memoizes this
51
+ // index for its lifetime and is the single writer (flush() keeps disk in
52
+ // sync for our own writes), so re-reading + re-parsing the whole file on
53
+ // every recall is pure overhead — the in-memory Map is already authoritative.
54
+ if (this.loaded) return;
55
+ let raw: string;
56
+ try {
57
+ raw = await fs.readFile(this.filePath, 'utf8');
58
+ } catch (err) {
59
+ if (!isEnoent(err)) throw err;
60
+ // No file on disk yet — still treat as loaded so subsequent recalls don't
61
+ // re-stat. This process is the single writer; flush() creates the file.
62
+ this.loaded = true;
63
+ return;
64
+ }
65
+ // The cache is a pure optimization: a corrupt/garbled/half-synced file
66
+ // (truncated JSON, manual edit, partial cloud-drive sync, schema drift) must
67
+ // degrade to a COLD cache, never permanently break every recall. Parse and
68
+ // shape-check defensively; on any failure, log and start empty.
69
+ this.loaded = true;
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch (err) {
74
+ console.warn(`[plugin-memory] ignoring corrupt embedding cache (${this.filePath}): ${String(err)}`);
75
+ return;
76
+ }
77
+ if (!isValidIndexFile(parsed)) return; // malformed shape → cold cache
78
+ if (parsed.version !== INDEX_VERSION) return; // unknown format, ignore
79
+ if (parsed.embedder !== this.embedderName) return; // embedder changed, invalidate
80
+ // Dim mismatch (incl. an old file written before dim was tracked) → the
81
+ // vectors are a different dimensionality; invalidate rather than mix them.
82
+ if (this.dim !== undefined && parsed.dim !== this.dim) return;
83
+ for (const [name, entry] of Object.entries(parsed.entries)) {
84
+ if (isValidIndexEntry(entry)) this.cache.set(name, entry);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * For a `(name, body)` pair, return either the cached vector (if the body
90
+ * hash matches) or `null` (miss). Callers re-embed the misses and call
91
+ * `set()` with the fresh vectors.
92
+ */
93
+ lookup(name: string, body: string): ReadonlyArray<number> | null {
94
+ const entry = this.cache.get(name);
95
+ if (!entry) return null;
96
+ if (entry.hash !== EmbeddingIndex.hash(body)) return null;
97
+ return entry.vector;
98
+ }
99
+
100
+ set(name: string, body: string, vector: ReadonlyArray<number>): void {
101
+ const hash = EmbeddingIndex.hash(body);
102
+ const existing = this.cache.get(name);
103
+ if (existing && existing.hash === hash) return;
104
+ this.cache.set(name, { hash, vector });
105
+ this.dirty = true;
106
+ }
107
+
108
+ /** Drop entries that no longer correspond to existing memories. */
109
+ prune(currentNames: ReadonlyArray<string>): void {
110
+ const wanted = new Set(currentNames);
111
+ for (const name of [...this.cache.keys()]) {
112
+ if (!wanted.has(name)) {
113
+ this.cache.delete(name);
114
+ this.dirty = true;
115
+ }
116
+ }
117
+ }
118
+
119
+ async flush(): Promise<void> {
120
+ if (!this.dirty) return;
121
+ const data: IndexFile = {
122
+ version: INDEX_VERSION,
123
+ embedder: this.embedderName,
124
+ ...(this.dim !== undefined ? { dim: this.dim } : {}),
125
+ entries: Object.fromEntries(this.cache),
126
+ };
127
+ await writeFileAtomic(this.filePath, JSON.stringify(data), { mode: 0o600 });
128
+ this.dirty = false;
129
+ }
130
+
131
+ get size(): number {
132
+ return this.cache.size;
133
+ }
134
+ }
135
+
136
+ function isEnoent(err: unknown): boolean {
137
+ return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
138
+ }
139
+
140
+ /** Structural guard so a valid-JSON-but-wrong-shape file (e.g. a missing
141
+ * `entries`) degrades to a cold cache instead of throwing in load(). */
142
+ function isValidIndexFile(value: unknown): value is IndexFile {
143
+ if (typeof value !== 'object' || value === null) return false;
144
+ const v = value as Record<string, unknown>;
145
+ return typeof v.entries === 'object' && v.entries !== null;
146
+ }
147
+
148
+ function isValidIndexEntry(value: unknown): value is IndexEntry {
149
+ if (typeof value !== 'object' || value === null) return false;
150
+ const e = value as Record<string, unknown>;
151
+ return (
152
+ typeof e.hash === 'string' &&
153
+ Array.isArray(e.vector) &&
154
+ e.vector.every((n) => typeof n === 'number')
155
+ );
156
+ }
@@ -0,0 +1,67 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { ToolDef } from '@moxxy/sdk';
6
+ import { buildMemoryPlugin } from './index.js';
7
+ import { MemoryStore } from './store.js';
8
+
9
+ let tmp: string;
10
+ beforeEach(async () => {
11
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-idx-'));
12
+ });
13
+ afterEach(async () => {
14
+ await fs.rm(tmp, { recursive: true, force: true });
15
+ });
16
+
17
+ const toolByName = (name: string): ToolDef => {
18
+ const { plugin } = buildMemoryPlugin({ dir: tmp });
19
+ const tool = plugin.tools?.find((t) => t.name === name);
20
+ if (!tool) throw new Error(`tool ${name} not registered`);
21
+ return tool;
22
+ };
23
+
24
+ describe('memory tool input schemas reject path-traversal names', () => {
25
+ // The inproc isolator does NOT enforce the fs.write glob, so these Zod
26
+ // schemas are the sole guard preventing a hallucinated/hostile `name` from
27
+ // reaching fs.unlink / fs.readFile outside the memory dir.
28
+ const traversals = ['../../../etc/passwd', '../escape', 'a/b', '/abs/path', '..', '.hidden', 'Has Space'];
29
+
30
+ it.each(['memory_forget', 'memory_update'])('%s rejects traversal names', (toolName) => {
31
+ const schema = toolByName(toolName).inputSchema;
32
+ for (const name of traversals) {
33
+ expect(schema.safeParse({ name }).success).toBe(false);
34
+ }
35
+ // A clean slug still parses.
36
+ expect(schema.safeParse({ name: 'valid-slug-1' }).success).toBe(true);
37
+ });
38
+ });
39
+
40
+ describe('MemoryStore path containment (belt-and-suspenders)', () => {
41
+ it('forget refuses a traversal name instead of unlinking outside the dir', async () => {
42
+ const store = new MemoryStore({ dir: tmp });
43
+ // Plant a sentinel file OUTSIDE the memory dir.
44
+ const outside = path.join(tmp, '..', `sentinel-${path.basename(tmp)}.md`);
45
+ await fs.writeFile(outside, 'do not delete');
46
+ try {
47
+ await expect(store.forget('../' + path.basename(outside).replace(/\.md$/, ''))).rejects.toThrow(
48
+ /invalid memory name/,
49
+ );
50
+ // Sentinel survives.
51
+ await expect(fs.access(outside)).resolves.toBeUndefined();
52
+ } finally {
53
+ await fs.rm(outside, { force: true });
54
+ }
55
+ });
56
+
57
+ it('get refuses a name with embedded separators', async () => {
58
+ const store = new MemoryStore({ dir: tmp });
59
+ await expect(store.get('nested/name')).rejects.toThrow(/invalid memory name/);
60
+ });
61
+
62
+ it('round-trips a clean slug name unchanged', async () => {
63
+ const store = new MemoryStore({ dir: tmp });
64
+ await store.save({ name: 'clean-slug', type: 'fact', description: 'd', body: 'b' });
65
+ expect((await store.get('clean-slug'))?.frontmatter.name).toBe('clean-slug');
66
+ });
67
+ });