@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.
- package/LICENSE +21 -0
- package/dist/consolidate.d.ts +62 -0
- package/dist/consolidate.d.ts.map +1 -0
- package/dist/consolidate.js +417 -0
- package/dist/consolidate.js.map +1 -0
- package/dist/embedding-cache.d.ts +32 -0
- package/dist/embedding-cache.d.ts.map +1 -0
- package/dist/embedding-cache.js +146 -0
- package/dist/embedding-cache.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/parse.d.ts +5 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +9 -0
- package/dist/parse.js.map +1 -0
- package/dist/stm.d.ts +20 -0
- package/dist/stm.d.ts.map +1 -0
- package/dist/stm.js +38 -0
- package/dist/stm.js.map +1 -0
- package/dist/store/io.d.ts +13 -0
- package/dist/store/io.d.ts.map +1 -0
- package/dist/store/io.js +124 -0
- package/dist/store/io.js.map +1 -0
- package/dist/store/search.d.ts +10 -0
- package/dist/store/search.d.ts.map +1 -0
- package/dist/store/search.js +160 -0
- package/dist/store/search.js.map +1 -0
- package/dist/store/types.d.ts +33 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +11 -0
- package/dist/store/types.js.map +1 -0
- package/dist/store.d.ts +95 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +220 -0
- package/dist/store.js.map +1 -0
- package/dist/tfidf.d.ts +29 -0
- package/dist/tfidf.d.ts.map +1 -0
- package/dist/tfidf.js +103 -0
- package/dist/tfidf.js.map +1 -0
- package/package.json +62 -0
- package/src/consolidate-nudge.test.ts +98 -0
- package/src/consolidate.test.ts +328 -0
- package/src/consolidate.ts +535 -0
- package/src/discovery.test.ts +33 -0
- package/src/embedding-cache.test.ts +268 -0
- package/src/embedding-cache.ts +156 -0
- package/src/index.test.ts +67 -0
- package/src/index.ts +247 -0
- package/src/parse.ts +12 -0
- package/src/stm.test.ts +69 -0
- package/src/stm.ts +48 -0
- package/src/store/io.test.ts +100 -0
- package/src/store/io.ts +138 -0
- package/src/store/search.test.ts +185 -0
- package/src/store/search.ts +197 -0
- package/src/store/types.ts +23 -0
- package/src/store.test.ts +186 -0
- package/src/store.ts +292 -0
- package/src/tfidf.test.ts +59 -0
- package/src/tfidf.ts +105 -0
- 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
|
+
});
|