@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
package/src/store.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { createMutex, type EmbeddingProvider, type Mutex } from '@moxxy/sdk';
|
|
4
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
5
|
+
import { renderFrontmatter } from './parse.js';
|
|
6
|
+
import { TfIdfEmbedder } from './tfidf.js';
|
|
7
|
+
import { EmbeddingIndex } from './embedding-cache.js';
|
|
8
|
+
import {
|
|
9
|
+
memoryFrontmatterSchema,
|
|
10
|
+
type MemoryEntry,
|
|
11
|
+
type MemoryFrontmatter,
|
|
12
|
+
type MemoryType,
|
|
13
|
+
type RecallMode,
|
|
14
|
+
} from './store/types.js';
|
|
15
|
+
import { isEnoent, listEntries, readEntry, safeRead, writeIndex, type IndexRow } from './store/io.js';
|
|
16
|
+
import { rankByKeywords, recallVector, type RankedMemory } from './store/search.js';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
memoryTypeSchema,
|
|
20
|
+
memoryFrontmatterSchema,
|
|
21
|
+
type MemoryEntry,
|
|
22
|
+
type MemoryFrontmatter,
|
|
23
|
+
type MemoryType,
|
|
24
|
+
type RecallMode,
|
|
25
|
+
} from './store/types.js';
|
|
26
|
+
export type { RankedMemory } from './store/search.js';
|
|
27
|
+
|
|
28
|
+
export interface MemoryStoreOptions {
|
|
29
|
+
readonly dir?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Optional embedding provider. When supplied, `recall()` uses cosine
|
|
32
|
+
* similarity over dense vectors. When omitted, the built-in TF-IDF
|
|
33
|
+
* embedder is used. Pass `embedder: null` to force keyword-only recall.
|
|
34
|
+
*
|
|
35
|
+
* May also be a lazy resolver `() => EmbeddingProvider | null`, resolved once
|
|
36
|
+
* on first recall — so the host can wire the registry-selected embedder
|
|
37
|
+
* (`() => session.embedders.tryGetActive()`) that isn't known until plugins
|
|
38
|
+
* have loaded, without forcing the store to be built after the session.
|
|
39
|
+
*/
|
|
40
|
+
readonly embedder?: EmbeddingProvider | null | (() => EmbeddingProvider | null);
|
|
41
|
+
/**
|
|
42
|
+
* Cache computed embeddings on disk (`<dir>/.embeddings.json`) so unchanged
|
|
43
|
+
* memories aren't re-embedded on every recall. Defaults to true for all
|
|
44
|
+
* embedders EXCEPT TF-IDF (which derives vocab from the whole corpus, so
|
|
45
|
+
* per-entry caching doesn't help).
|
|
46
|
+
*/
|
|
47
|
+
readonly persistEmbeddings?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Soft cap on the number of stored memories. WARN-ONLY by design: memories
|
|
50
|
+
* are deliberately-saved user knowledge consumed both via `recall` and via
|
|
51
|
+
* the MEMORY.md index agents read directly, so silent oldest-eviction would
|
|
52
|
+
* be silent data loss. When a save pushes the store past this cap, the save
|
|
53
|
+
* still succeeds but a warning is logged and surfaced through
|
|
54
|
+
* {@link MemoryStore.capStatus} (the `memory_save` tool relays it to the
|
|
55
|
+
* model so it can consolidate or `memory_forget` stale entries).
|
|
56
|
+
*/
|
|
57
|
+
readonly maxMemories?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Default soft cap — see {@link MemoryStoreOptions.maxMemories}. */
|
|
61
|
+
export const DEFAULT_MAX_MEMORIES = 500;
|
|
62
|
+
|
|
63
|
+
export function defaultMemoryDir(): string {
|
|
64
|
+
return moxxyPath('memory');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class MemoryStore {
|
|
68
|
+
readonly dir: string;
|
|
69
|
+
private readonly resolveEmbedder: () => EmbeddingProvider | null;
|
|
70
|
+
private readonly persistOpt: boolean | undefined;
|
|
71
|
+
// Embedder + index are resolved LAZILY on first recall (memoized), so a host
|
|
72
|
+
// that selects the embedder from a registry after plugins load can pass a
|
|
73
|
+
// resolver here without the store needing to be built after the session.
|
|
74
|
+
private embedderCache: EmbeddingProvider | null | undefined;
|
|
75
|
+
private indexCache: EmbeddingIndex | null | undefined;
|
|
76
|
+
// Per-instance mutex. save/update/forget/recall each read-modify-write the
|
|
77
|
+
// entry file, MEMORY.md, and the embedding index; without serialization two
|
|
78
|
+
// overlapping calls clobber MEMORY.md and race the embedding cache.
|
|
79
|
+
private readonly mutex: Mutex = createMutex();
|
|
80
|
+
private readonly maxMemories: number;
|
|
81
|
+
// In-memory MEMORY.md rows (name → frontmatter+path), hydrated lazily from
|
|
82
|
+
// disk ONCE, then maintained incrementally on save/update/forget — so a
|
|
83
|
+
// write no longer re-reads + re-parses every memory file to rebuild the
|
|
84
|
+
// index (the old O(N)-per-write rebuild). Entries written to the dir by
|
|
85
|
+
// other processes appear after the next hydration (new store instance);
|
|
86
|
+
// within one process this store is the only writer, same assumption the
|
|
87
|
+
// mutex already makes.
|
|
88
|
+
private indexRows: Map<string, IndexRow> | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(opts: MemoryStoreOptions = {}) {
|
|
91
|
+
this.dir = opts.dir ?? defaultMemoryDir();
|
|
92
|
+
this.persistOpt = opts.persistEmbeddings;
|
|
93
|
+
this.maxMemories = opts.maxMemories ?? DEFAULT_MAX_MEMORIES;
|
|
94
|
+
const e = opts.embedder;
|
|
95
|
+
if (typeof e === 'function') {
|
|
96
|
+
this.resolveEmbedder = e;
|
|
97
|
+
} else if (e === null) {
|
|
98
|
+
this.resolveEmbedder = () => null;
|
|
99
|
+
} else if (e !== undefined) {
|
|
100
|
+
this.resolveEmbedder = () => e;
|
|
101
|
+
} else {
|
|
102
|
+
const tfidf = new TfIdfEmbedder();
|
|
103
|
+
this.resolveEmbedder = () => tfidf;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private getEmbedder(): EmbeddingProvider | null {
|
|
108
|
+
if (this.embedderCache === undefined) this.embedderCache = this.resolveEmbedder();
|
|
109
|
+
return this.embedderCache;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private getIndex(): EmbeddingIndex | null {
|
|
113
|
+
if (this.indexCache === undefined) {
|
|
114
|
+
const emb = this.getEmbedder();
|
|
115
|
+
// TF-IDF's vocab depends on the whole corpus, so per-entry caching is
|
|
116
|
+
// useless — recompute every recall. For neural embedders, caching is
|
|
117
|
+
// a big win since each entry's vector is corpus-independent.
|
|
118
|
+
const isTfIdf = emb instanceof TfIdfEmbedder;
|
|
119
|
+
const persist = this.persistOpt ?? (emb !== null && !isTfIdf);
|
|
120
|
+
this.indexCache = persist && emb ? new EmbeddingIndex(this.dir, emb.name, emb.dim) : null;
|
|
121
|
+
}
|
|
122
|
+
return this.indexCache;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get embedderName(): string {
|
|
126
|
+
return this.getEmbedder()?.name ?? 'keyword';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
list(filterType?: MemoryType): Promise<ReadonlyArray<MemoryEntry>> {
|
|
130
|
+
return listEntries(this.dir, filterType);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get(name: string): Promise<MemoryEntry | null> {
|
|
134
|
+
// fileFor() throws synchronously on a traversal/separator name; surface it
|
|
135
|
+
// as a rejected promise so callers see one consistent (async) failure mode
|
|
136
|
+
// instead of an exception thrown before the promise is even constructed.
|
|
137
|
+
let file: string;
|
|
138
|
+
try {
|
|
139
|
+
file = this.fileFor(name);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return Promise.reject(err);
|
|
142
|
+
}
|
|
143
|
+
return readEntry(file);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
save(
|
|
147
|
+
input: Omit<MemoryFrontmatter, 'createdAt' | 'updatedAt'> & { body: string },
|
|
148
|
+
): Promise<MemoryEntry> {
|
|
149
|
+
return this.mutex.run(() => this.writeEntry(input));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
update(
|
|
153
|
+
name: string,
|
|
154
|
+
patch: { body?: string; description?: string; tags?: ReadonlyArray<string> },
|
|
155
|
+
): Promise<MemoryEntry | null> {
|
|
156
|
+
// Read-modify-write under the mutex; calls the internal (unserialized)
|
|
157
|
+
// writer so it doesn't deadlock on its own chain.
|
|
158
|
+
return this.mutex.run(async () => {
|
|
159
|
+
const existing = await readEntry(this.fileFor(name));
|
|
160
|
+
if (!existing) return null;
|
|
161
|
+
const mergedTags = patch.tags ?? existing.frontmatter.tags;
|
|
162
|
+
return this.writeEntry({
|
|
163
|
+
name: existing.frontmatter.name,
|
|
164
|
+
type: existing.frontmatter.type,
|
|
165
|
+
description: patch.description ?? existing.frontmatter.description,
|
|
166
|
+
...(mergedTags ? { tags: [...mergedTags] } : {}),
|
|
167
|
+
body: patch.body ?? existing.body,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
forget(name: string): Promise<boolean> {
|
|
173
|
+
return this.mutex.run(async () => {
|
|
174
|
+
const filePath = this.fileFor(name);
|
|
175
|
+
try {
|
|
176
|
+
await fs.unlink(filePath);
|
|
177
|
+
const rows = await this.rows();
|
|
178
|
+
rows.delete(name);
|
|
179
|
+
await this.writeIndexFromRows(rows);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (isEnoent(err)) return false;
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Soft-cap status (see {@link MemoryStoreOptions.maxMemories}). `over` means
|
|
190
|
+
* the store holds more entries than the cap; nothing is ever evicted.
|
|
191
|
+
*/
|
|
192
|
+
async capStatus(): Promise<{ count: number; max: number; over: boolean }> {
|
|
193
|
+
// Read the index-row cache under the mutex: writeEntry/forget mutate it
|
|
194
|
+
// while holding the same lock, so a concurrent capStatus() outside the lock
|
|
195
|
+
// could observe a half-updated map or trigger a redundant disk hydration.
|
|
196
|
+
const size = await this.mutex.run(async () => (await this.rows()).size);
|
|
197
|
+
return { count: size, max: this.maxMemories, over: size > this.maxMemories };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The actual entry write. NOT serialized — callers hold the mutex. */
|
|
201
|
+
private async writeEntry(
|
|
202
|
+
input: Omit<MemoryFrontmatter, 'createdAt' | 'updatedAt'> & { body: string },
|
|
203
|
+
): Promise<MemoryEntry> {
|
|
204
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
205
|
+
const filePath = this.fileFor(input.name);
|
|
206
|
+
const existing = await safeRead(filePath);
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const createdAt = existing?.frontmatter.createdAt ?? now;
|
|
209
|
+
const frontmatter = memoryFrontmatterSchema.parse({
|
|
210
|
+
name: input.name,
|
|
211
|
+
type: input.type,
|
|
212
|
+
description: input.description,
|
|
213
|
+
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
|
214
|
+
createdAt,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
});
|
|
217
|
+
const content = `${renderFrontmatter(frontmatter)}\n\n${input.body.trimEnd()}\n`;
|
|
218
|
+
await writeFileAtomic(filePath, content);
|
|
219
|
+
// Incremental index maintenance: update THIS entry's row and re-render
|
|
220
|
+
// MEMORY.md from the in-memory rows — no per-write re-read of every file.
|
|
221
|
+
const rows = await this.rows();
|
|
222
|
+
rows.set(frontmatter.name, { frontmatter, path: filePath });
|
|
223
|
+
await this.writeIndexFromRows(rows);
|
|
224
|
+
if (rows.size > this.maxMemories) {
|
|
225
|
+
// Warn-only soft cap — eviction would silently destroy saved knowledge.
|
|
226
|
+
console.warn(
|
|
227
|
+
`[plugin-memory] memory store holds ${rows.size} entries (soft cap ${this.maxMemories}). ` +
|
|
228
|
+
`Nothing is evicted; consider consolidating or forgetting stale memories.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return { frontmatter, body: input.body.trimEnd(), path: filePath };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Search memories by a free-text query. Uses vector cosine similarity when
|
|
236
|
+
* an EmbeddingProvider is configured (the default is the built-in TF-IDF
|
|
237
|
+
* embedder); falls back to keyword scoring when `mode: 'keyword'` or when
|
|
238
|
+
* no embedder is wired.
|
|
239
|
+
*/
|
|
240
|
+
async recall(
|
|
241
|
+
query: string,
|
|
242
|
+
opts: { limit?: number; type?: MemoryType; mode?: RecallMode } = {},
|
|
243
|
+
): Promise<ReadonlyArray<RankedMemory>> {
|
|
244
|
+
const limit = opts.limit ?? 5;
|
|
245
|
+
const mode = opts.mode ?? 'auto';
|
|
246
|
+
const all = await this.list(opts.type);
|
|
247
|
+
if (all.length === 0) return [];
|
|
248
|
+
|
|
249
|
+
const embedder = this.getEmbedder();
|
|
250
|
+
const useVector = mode === 'vector' || (mode === 'auto' && embedder !== null);
|
|
251
|
+
if (useVector && embedder) {
|
|
252
|
+
return recallVector(all, query, limit, embedder, this.getIndex(), this.mutex);
|
|
253
|
+
}
|
|
254
|
+
return rankByKeywords(all, query, limit);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private fileFor(name: string): string {
|
|
258
|
+
// Belt-and-suspenders containment: the tool schemas restrict `name` to a
|
|
259
|
+
// slug, but the store is a public API and the inproc isolator can't enforce
|
|
260
|
+
// the fs glob — so refuse any name that escapes the memory dir (traversal,
|
|
261
|
+
// absolute path, or embedded separators) rather than unlink/read outside it.
|
|
262
|
+
const root = path.resolve(this.dir);
|
|
263
|
+
const resolved = path.resolve(root, `${name}.md`);
|
|
264
|
+
// The resolved file must live DIRECTLY in `root` (no nested or escaped path).
|
|
265
|
+
if (path.dirname(resolved) !== root) {
|
|
266
|
+
throw new Error(`invalid memory name: ${name}`);
|
|
267
|
+
}
|
|
268
|
+
return resolved;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Hydrate the index-row cache from disk once, then serve it from memory.
|
|
272
|
+
* Callers that mutate it hold the store mutex (writeEntry/forget). */
|
|
273
|
+
private async rows(): Promise<Map<string, IndexRow>> {
|
|
274
|
+
if (!this.indexRows) {
|
|
275
|
+
const entries = await listEntries(this.dir);
|
|
276
|
+
this.indexRows = new Map(
|
|
277
|
+
entries.map((e) => [e.frontmatter.name, { frontmatter: e.frontmatter, path: e.path }]),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return this.indexRows;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Render MEMORY.md from the cached rows, name-sorted so the output is
|
|
284
|
+
* deterministic (readdir order, which the old full rebuild inherited,
|
|
285
|
+
* was platform-dependent anyway). */
|
|
286
|
+
private writeIndexFromRows(rows: Map<string, IndexRow>): Promise<void> {
|
|
287
|
+
const sorted = [...rows.values()].sort((a, b) =>
|
|
288
|
+
a.frontmatter.name.localeCompare(b.frontmatter.name),
|
|
289
|
+
);
|
|
290
|
+
return writeIndex(this.dir, sorted);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TfIdfEmbedder, cosineSimilarity, tokenize } from './tfidf.js';
|
|
3
|
+
|
|
4
|
+
describe('tokenize', () => {
|
|
5
|
+
it('lowercases, splits on non-alphanumerics, drops short tokens + stopwords', () => {
|
|
6
|
+
expect(tokenize('The QUICK brown fox')).toEqual(['quick', 'brown', 'fox']);
|
|
7
|
+
expect(tokenize('foo-bar baz_qux 123abc')).toEqual(['foo-bar', 'baz_qux', '123abc']);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('strips diacritics for stable matching', () => {
|
|
11
|
+
expect(tokenize('café naïve')).toEqual(['cafe', 'naive']);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('TfIdfEmbedder', () => {
|
|
16
|
+
it('embed() before fit() returns empty vectors', async () => {
|
|
17
|
+
const e = new TfIdfEmbedder();
|
|
18
|
+
const [v] = await e.embed(['hello world']);
|
|
19
|
+
expect(v).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('produces vectors over a fitted vocab', async () => {
|
|
23
|
+
const corpus = [
|
|
24
|
+
'team prefers tRPC over REST',
|
|
25
|
+
'production runs Postgres 16',
|
|
26
|
+
'sentry alerts go to slack',
|
|
27
|
+
];
|
|
28
|
+
const e = new TfIdfEmbedder();
|
|
29
|
+
e.fit(corpus);
|
|
30
|
+
const vectors = await e.embed(corpus);
|
|
31
|
+
expect(vectors).toHaveLength(3);
|
|
32
|
+
expect(vectors[0]!.length).toBeGreaterThan(0);
|
|
33
|
+
expect(vectors[0]!.length).toBe(vectors[1]!.length);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('ranks a relevant query higher than an irrelevant one', async () => {
|
|
37
|
+
const corpus = [
|
|
38
|
+
'team prefers tRPC over REST for endpoints',
|
|
39
|
+
'production database is Postgres 16',
|
|
40
|
+
'feature flags live in GrowthBook',
|
|
41
|
+
];
|
|
42
|
+
const e = new TfIdfEmbedder();
|
|
43
|
+
e.fit([...corpus, 'what API style does the team use', 'database flavor in prod']);
|
|
44
|
+
const v = await e.embed([...corpus, 'what API style does the team use']);
|
|
45
|
+
const queryVec = v[v.length - 1]!;
|
|
46
|
+
const scores = corpus.map((_, i) => cosineSimilarity(v[i]!, queryVec));
|
|
47
|
+
// tRPC entry should be most similar to "API style" query
|
|
48
|
+
expect(scores[0]).toBeGreaterThan(scores[1]!);
|
|
49
|
+
expect(scores[0]).toBeGreaterThan(scores[2]!);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('cosineSimilarity returns 1 for identical normalized vectors', () => {
|
|
53
|
+
expect(cosineSimilarity([0.6, 0.8], [0.6, 0.8])).toBeCloseTo(1, 5);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('cosineSimilarity returns 0 for orthogonal vectors', () => {
|
|
57
|
+
expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0, 5);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/tfidf.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { EmbeddingProvider } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in zero-dependency TF-IDF embedder. Computes a sparse-but-stored-dense
|
|
5
|
+
* vector per text. Builds vocabulary lazily from the corpus you provide via
|
|
6
|
+
* `fit()`. Once fit, `embed()` produces vectors over that fixed vocabulary.
|
|
7
|
+
*
|
|
8
|
+
* Quality is meh by neural-embedding standards but: deterministic, runs in
|
|
9
|
+
* <1ms for hundreds of entries, no network, no model download, no API key.
|
|
10
|
+
* Real semantic embeddings can be wired by passing a different EmbeddingProvider
|
|
11
|
+
* to MemoryStore.
|
|
12
|
+
*/
|
|
13
|
+
export class TfIdfEmbedder implements EmbeddingProvider {
|
|
14
|
+
readonly name = 'tfidf';
|
|
15
|
+
private vocab: ReadonlyArray<string> = [];
|
|
16
|
+
private vocabIndex = new Map<string, number>();
|
|
17
|
+
private idf: ReadonlyArray<number> = [];
|
|
18
|
+
|
|
19
|
+
get dim(): number | 'dynamic' {
|
|
20
|
+
return this.vocab.length || 'dynamic';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute vocabulary + IDF weights from the corpus. Call this whenever the
|
|
25
|
+
* source corpus changes; embed() must be called only after fit().
|
|
26
|
+
*/
|
|
27
|
+
fit(corpus: ReadonlyArray<string>): void {
|
|
28
|
+
const docs = corpus.map(tokenize);
|
|
29
|
+
const df = new Map<string, number>();
|
|
30
|
+
for (const doc of docs) {
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
for (const t of doc) {
|
|
33
|
+
if (seen.has(t)) continue;
|
|
34
|
+
seen.add(t);
|
|
35
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Filter: keep tokens appearing at least once and at most in 95% of docs.
|
|
39
|
+
const N = Math.max(1, docs.length);
|
|
40
|
+
const maxDf = Math.max(1, Math.ceil(N * 0.95));
|
|
41
|
+
const kept: Array<[string, number]> = [];
|
|
42
|
+
for (const [token, count] of df) {
|
|
43
|
+
if (count <= maxDf) kept.push([token, count]);
|
|
44
|
+
}
|
|
45
|
+
kept.sort((a, b) => a[0].localeCompare(b[0]));
|
|
46
|
+
this.vocab = kept.map((x) => x[0]);
|
|
47
|
+
this.vocabIndex = new Map(this.vocab.map((t, i) => [t, i]));
|
|
48
|
+
this.idf = kept.map(([, dfi]) => Math.log((N + 1) / (dfi + 1)) + 1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
embedSync(text: string): number[] {
|
|
52
|
+
if (this.vocab.length === 0) return [];
|
|
53
|
+
const tokens = tokenize(text);
|
|
54
|
+
const tf = new Map<number, number>();
|
|
55
|
+
for (const t of tokens) {
|
|
56
|
+
const idx = this.vocabIndex.get(t);
|
|
57
|
+
if (idx === undefined) continue;
|
|
58
|
+
tf.set(idx, (tf.get(idx) ?? 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
const vec = new Array<number>(this.vocab.length).fill(0);
|
|
61
|
+
const docLen = tokens.length || 1;
|
|
62
|
+
for (const [idx, count] of tf) {
|
|
63
|
+
vec[idx] = (count / docLen) * (this.idf[idx] ?? 1);
|
|
64
|
+
}
|
|
65
|
+
return l2Normalize(vec);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async embed(texts: ReadonlyArray<string>): Promise<ReadonlyArray<ReadonlyArray<number>>> {
|
|
69
|
+
return texts.map((t) => this.embedSync(t));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function cosineSimilarity(a: ReadonlyArray<number>, b: ReadonlyArray<number>): number {
|
|
74
|
+
const n = Math.min(a.length, b.length);
|
|
75
|
+
let dot = 0;
|
|
76
|
+
for (let i = 0; i < n; i++) dot += (a[i] ?? 0) * (b[i] ?? 0);
|
|
77
|
+
return dot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function l2Normalize(v: number[]): number[] {
|
|
81
|
+
let sum = 0;
|
|
82
|
+
for (const x of v) sum += x * x;
|
|
83
|
+
if (sum === 0) return v;
|
|
84
|
+
const norm = Math.sqrt(sum);
|
|
85
|
+
return v.map((x) => x / norm);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function tokenize(text: string): string[] {
|
|
89
|
+
return text
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.normalize('NFKD')
|
|
92
|
+
.replace(/[̀-ͯ]/g, '')
|
|
93
|
+
.split(/[^a-z0-9_-]+/)
|
|
94
|
+
.filter((t) => t.length >= 2 && !STOPWORDS.has(t));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const STOPWORDS = new Set([
|
|
98
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be',
|
|
99
|
+
'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
100
|
+
'should', 'could', 'may', 'might', 'must', 'shall', 'to', 'of', 'in', 'for',
|
|
101
|
+
'on', 'at', 'by', 'with', 'from', 'as', 'into', 'through', 'about', 'between',
|
|
102
|
+
'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them', 'their', 'we',
|
|
103
|
+
'us', 'our', 'you', 'your', 'he', 'she', 'his', 'her', 'i', 'me', 'my',
|
|
104
|
+
'so', 'than', 'too', 'very', 'just', 'not', 'no', 'yes', 'if', 'then',
|
|
105
|
+
]);
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { EmbeddingProvider } from '@moxxy/sdk';
|
|
6
|
+
import { MemoryStore } from './store.js';
|
|
7
|
+
|
|
8
|
+
let tmp: string;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-vec-'));
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const sampleCorpus = async (store: MemoryStore): Promise<void> => {
|
|
17
|
+
await store.save({
|
|
18
|
+
name: 'trpc-preference',
|
|
19
|
+
type: 'preference',
|
|
20
|
+
description: 'Team prefers tRPC API over REST API.',
|
|
21
|
+
body: 'When generating new API endpoints scaffold a tRPC procedure rather than a REST controller. tRPC API style is the standard.',
|
|
22
|
+
});
|
|
23
|
+
await store.save({
|
|
24
|
+
name: 'postgres-prod',
|
|
25
|
+
type: 'project',
|
|
26
|
+
description: 'Production database is Postgres 16.',
|
|
27
|
+
body: 'All migrations target Postgres 16. Run pg_dump for backups every night.',
|
|
28
|
+
});
|
|
29
|
+
await store.save({
|
|
30
|
+
name: 'growthbook-flags',
|
|
31
|
+
type: 'reference',
|
|
32
|
+
description: 'Feature flags live in GrowthBook.',
|
|
33
|
+
body: 'GrowthBook owns flag definitions. The SDK is initialized in app/bootstrap.ts.',
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('MemoryStore vector recall (TF-IDF default)', () => {
|
|
38
|
+
it('uses vector mode by default when an embedder is configured', async () => {
|
|
39
|
+
const store = new MemoryStore({ dir: tmp });
|
|
40
|
+
expect(store.embedderName).toBe('tfidf');
|
|
41
|
+
await sampleCorpus(store);
|
|
42
|
+
const matches = await store.recall('what API style does the team prefer');
|
|
43
|
+
expect(matches[0]!.entry.frontmatter.name).toBe('trpc-preference');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('vector recall ranks semantically relevant entries above incidental keyword matches', async () => {
|
|
47
|
+
const store = new MemoryStore({ dir: tmp });
|
|
48
|
+
await sampleCorpus(store);
|
|
49
|
+
const matches = await store.recall('postgres database backups', { limit: 3 });
|
|
50
|
+
expect(matches[0]!.entry.frontmatter.name).toBe('postgres-prod');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('mode: "keyword" forces the legacy scorer', async () => {
|
|
54
|
+
const store = new MemoryStore({ dir: tmp });
|
|
55
|
+
await sampleCorpus(store);
|
|
56
|
+
const matches = await store.recall('postgres', { mode: 'keyword' });
|
|
57
|
+
expect(matches[0]!.entry.frontmatter.name).toBe('postgres-prod');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('embedder: null disables vector entirely and forces keyword', async () => {
|
|
61
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
62
|
+
expect(store.embedderName).toBe('keyword');
|
|
63
|
+
await sampleCorpus(store);
|
|
64
|
+
const matches = await store.recall('database flavor in prod');
|
|
65
|
+
// Falls through to keyword: "database" + "prod" only appear in postgres-prod
|
|
66
|
+
expect(matches[0]!.entry.frontmatter.name).toBe('postgres-prod');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('accepts a custom EmbeddingProvider via the constructor', async () => {
|
|
70
|
+
const stub: EmbeddingProvider = {
|
|
71
|
+
name: 'stub',
|
|
72
|
+
dim: 3,
|
|
73
|
+
async embed(texts) {
|
|
74
|
+
return texts.map((t) => (t.includes('postgres') ? [1, 0, 0] : [0, 1, 0]));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const store = new MemoryStore({ dir: tmp, embedder: stub });
|
|
78
|
+
expect(store.embedderName).toBe('stub');
|
|
79
|
+
await sampleCorpus(store);
|
|
80
|
+
const matches = await store.recall('postgres');
|
|
81
|
+
expect(matches[0]!.entry.frontmatter.name).toBe('postgres-prod');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns empty list when corpus is empty', async () => {
|
|
85
|
+
const store = new MemoryStore({ dir: tmp });
|
|
86
|
+
expect(await store.recall('anything')).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|