@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
package/src/index.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { defineTool, defineEmbedder, definePlugin, z, type Plugin } from '@moxxy/sdk';
2
+ import { MemoryStore, memoryTypeSchema, type MemoryStoreOptions } from './store.js';
3
+ import { TfIdfEmbedder } from './tfidf.js';
4
+
5
+ export {
6
+ MemoryStore,
7
+ memoryTypeSchema,
8
+ memoryFrontmatterSchema,
9
+ defaultMemoryDir,
10
+ type MemoryEntry,
11
+ type MemoryFrontmatter,
12
+ type MemoryStoreOptions,
13
+ type MemoryType,
14
+ type RankedMemory,
15
+ type RecallMode,
16
+ } from './store.js';
17
+ export { parseMdFile, parseFrontmatter, renderFrontmatter } from './parse.js';
18
+ export { recentExchanges, summarizeSession, type SessionFact } from './stm.js';
19
+ export { TfIdfEmbedder, cosineSimilarity, tokenize } from './tfidf.js';
20
+ export { EmbeddingIndex } from './embedding-cache.js';
21
+ export {
22
+ planConsolidation,
23
+ consolidateMemory,
24
+ buildMemoryConsolidatePlugin,
25
+ memoryConsolidatePlugin,
26
+ type ConsolidatePlan,
27
+ type ConsolidateOptions,
28
+ type ConsolidationOutcome,
29
+ } from './consolidate.js';
30
+ import { memoryConsolidatePlugin as memoryConsolidatePluginRef } from './consolidate.js';
31
+
32
+ export interface BuildMemoryPluginOptions extends MemoryStoreOptions {}
33
+
34
+ export function buildMemoryPlugin(opts: BuildMemoryPluginOptions = {}): { plugin: Plugin; store: MemoryStore } {
35
+ const store = new MemoryStore(opts);
36
+ const plugin = definePlugin({
37
+ name: '@moxxy/plugin-memory',
38
+ version: '0.0.0',
39
+ // Publish the long-term store on the inter-plugin service registry so the
40
+ // sibling @moxxy/memory-consolidate plugin can resolve it in its own onInit
41
+ // instead of being hand-built with the store — the seam that lets both be
42
+ // discovery-loaded. memory-consolidate is registered after this plugin, so
43
+ // this onInit runs first.
44
+ hooks: {
45
+ onInit: (ctx) => {
46
+ ctx.services.register('memory', store);
47
+ },
48
+ },
49
+ // The zero-dep TF-IDF embedder, contributed as a selectable embedder so it
50
+ // sits in the same registry as openai/transformers/custom ones.
51
+ embedders: [
52
+ defineEmbedder({
53
+ name: 'tfidf',
54
+ displayName: 'TF-IDF (built-in, zero-dep)',
55
+ createClient: () => new TfIdfEmbedder(),
56
+ }),
57
+ ],
58
+ tools: [
59
+ defineTool({
60
+ name: 'memory_save',
61
+ description:
62
+ 'Persist a memory to long-term storage. Use sparingly — only for facts/preferences/' +
63
+ 'project context that would help you in future sessions. Keep the body terse.',
64
+ inputSchema: z.object({
65
+ name: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, 'name must be slug-like'),
66
+ type: memoryTypeSchema,
67
+ description: z.string().min(1).max(280),
68
+ body: z.string().min(1).max(4000),
69
+ tags: z.array(z.string().min(1)).optional(),
70
+ }),
71
+ permission: { action: 'prompt' },
72
+ isolation: {
73
+ capabilities: {
74
+ fs: { read: ['~/.moxxy/memory/**'], write: ['~/.moxxy/memory/**'] },
75
+ net: { mode: 'none' },
76
+ timeMs: 5_000,
77
+ },
78
+ },
79
+ handler: async ({ name, type, description, body, tags }) => {
80
+ const saved = await store.save({ name, type, description, body, tags });
81
+ const cap = await store.capStatus();
82
+ return {
83
+ name: saved.frontmatter.name,
84
+ path: saved.path,
85
+ // Warn-only soft cap: the save succeeded, but tell the model the
86
+ // store is overgrown so it consolidates / forgets stale entries.
87
+ ...(cap.over
88
+ ? {
89
+ warning:
90
+ `memory store holds ${cap.count} entries (soft cap ${cap.max}). ` +
91
+ `Nothing was evicted — consider consolidating related memories or using memory_forget on stale ones.`,
92
+ }
93
+ : {}),
94
+ };
95
+ },
96
+ }),
97
+ defineTool({
98
+ name: 'memory_recall',
99
+ description:
100
+ 'Search long-term memory by free-text query. Uses vector similarity (TF-IDF by default, ' +
101
+ 'or a configured EmbeddingProvider) when mode is "auto" or "vector". Returns the most ' +
102
+ 'relevant entries with their full bodies.',
103
+ inputSchema: z.object({
104
+ query: z.string().min(1),
105
+ limit: z.number().int().min(1).max(20).optional().default(5),
106
+ type: memoryTypeSchema.optional(),
107
+ mode: z.enum(['auto', 'vector', 'keyword']).optional().default('auto'),
108
+ }),
109
+ isolation: {
110
+ capabilities: {
111
+ fs: { read: ['~/.moxxy/memory/**'] },
112
+ // Vector recall may call out to an EmbeddingProvider (OpenAI,
113
+ // local transformers, …). The inproc isolator can't enforce
114
+ // this; a stronger isolator should constrain to the actual
115
+ // configured embedder's host.
116
+ net: { mode: 'any' },
117
+ timeMs: 15_000,
118
+ },
119
+ },
120
+ handler: async ({ query, limit, type, mode }) => {
121
+ const matches = await store.recall(query, { limit, type, mode });
122
+ return matches.map(({ entry, score }) => ({
123
+ name: entry.frontmatter.name,
124
+ type: entry.frontmatter.type,
125
+ description: entry.frontmatter.description,
126
+ body: entry.body,
127
+ score,
128
+ }));
129
+ },
130
+ }),
131
+ defineTool({
132
+ name: 'memory_list',
133
+ description: 'List all stored memories (name + type + description, no body).',
134
+ inputSchema: z.object({ type: memoryTypeSchema.optional() }),
135
+ isolation: {
136
+ capabilities: {
137
+ fs: { read: ['~/.moxxy/memory/**'] },
138
+ net: { mode: 'none' },
139
+ timeMs: 5_000,
140
+ },
141
+ },
142
+ handler: async ({ type }) => {
143
+ const entries = await store.list(type);
144
+ return entries.map((e) => ({
145
+ name: e.frontmatter.name,
146
+ type: e.frontmatter.type,
147
+ description: e.frontmatter.description,
148
+ tags: e.frontmatter.tags ?? [],
149
+ }));
150
+ },
151
+ }),
152
+ defineTool({
153
+ name: 'memory_forget',
154
+ description: 'Delete a memory by name. Use only when the memory is incorrect or no longer relevant.',
155
+ // Slug-only name: the inproc isolator does NOT enforce the fs.write glob,
156
+ // so this Zod guard is the sole defense against a path-traversal `name`
157
+ // (e.g. '../../vault') reaching fs.unlink. Mirror memory_save's regex.
158
+ inputSchema: z.object({
159
+ name: z.string().min(1).max(120).regex(/^[a-z0-9][a-z0-9-]*$/, 'name must be slug-like'),
160
+ }),
161
+ permission: { action: 'prompt' },
162
+ isolation: {
163
+ capabilities: {
164
+ fs: { read: ['~/.moxxy/memory/**'], write: ['~/.moxxy/memory/**'] },
165
+ net: { mode: 'none' },
166
+ timeMs: 5_000,
167
+ },
168
+ },
169
+ handler: async ({ name }) => {
170
+ const removed = await store.forget(name);
171
+ return removed ? `forgot ${name}` : `not found: ${name}`;
172
+ },
173
+ }),
174
+ defineTool({
175
+ name: 'memory_update',
176
+ description: 'Update an existing memory in place. createdAt is preserved; updatedAt bumps.',
177
+ inputSchema: z.object({
178
+ // Slug-only: see memory_forget — the schema is the only traversal guard.
179
+ name: z.string().min(1).max(120).regex(/^[a-z0-9][a-z0-9-]*$/, 'name must be slug-like'),
180
+ description: z.string().min(1).max(280).optional(),
181
+ body: z.string().min(1).max(4000).optional(),
182
+ tags: z.array(z.string().min(1)).optional(),
183
+ }),
184
+ permission: { action: 'prompt' },
185
+ isolation: {
186
+ capabilities: {
187
+ fs: { read: ['~/.moxxy/memory/**'], write: ['~/.moxxy/memory/**'] },
188
+ net: { mode: 'none' },
189
+ timeMs: 5_000,
190
+ },
191
+ },
192
+ handler: async ({ name, description, body, tags }) => {
193
+ const updated = await store.update(name, { description, body, tags });
194
+ if (!updated) throw new Error(`memory '${name}' not found`);
195
+ return { name: updated.frontmatter.name, updatedAt: updated.frontmatter.updatedAt };
196
+ },
197
+ }),
198
+ ],
199
+ });
200
+ return { plugin, store };
201
+ }
202
+
203
+
204
+ /**
205
+ * Discovery-loadable instance: the WHOLE memory feature (long-term store +
206
+ * memory_save/recall/… tools + the tfidf embedder + memory_consolidate and
207
+ * its nudge hooks) as ONE plugin, so the package unbundles cleanly — the
208
+ * loader takes a single default export per package. Composition over the
209
+ * existing builders:
210
+ * - the store's embedder resolves LAZILY from the host-published
211
+ * 'embedders' registry service (captured in onInit, read on first
212
+ * recall), replacing the bootstrap closure the CLI used to inject;
213
+ * - our onInit registers the 'memory' service FIRST, then runs
214
+ * consolidate's onInit, which resolves it — same ordering the two
215
+ * separate builtin entries relied on.
216
+ * `buildMemoryPlugin` stays for hosts/tests that inject their own store.
217
+ */
218
+ export const memoryPlugin: Plugin = (() => {
219
+ let embeddersReg: { tryGetActive(): unknown } | null = null;
220
+ const { plugin: base } = buildMemoryPlugin({
221
+ // The registry hands back an EmbedderClient-compatible instance; the
222
+ // structural cast keeps this file free of a core import.
223
+ embedder: () =>
224
+ (embeddersReg?.tryGetActive() ?? null) as ReturnType<
225
+ Extract<NonNullable<MemoryStoreOptions['embedder']>, () => unknown>
226
+ >,
227
+ });
228
+ const consolidate = memoryConsolidatePluginRef;
229
+ return definePlugin({
230
+ name: '@moxxy/plugin-memory',
231
+ version: '0.0.0',
232
+ ...(base.embedders ? { embedders: base.embedders } : {}),
233
+ tools: [...(base.tools ?? []), ...(consolidate.tools ?? [])],
234
+ hooks: {
235
+ ...consolidate.hooks,
236
+ onInit: async (ctx) => {
237
+ embeddersReg =
238
+ ctx.services.get<{ tryGetActive(): unknown }>('embedders') ?? null;
239
+ await base.hooks?.onInit?.(ctx);
240
+ await consolidate.hooks?.onInit?.(ctx);
241
+ },
242
+ },
243
+ });
244
+ })();
245
+
246
+ // Discovery entry: `createPluginLoader` requires a default Plugin export.
247
+ export default memoryPlugin;
package/src/parse.ts ADDED
@@ -0,0 +1,12 @@
1
+ // The MD+frontmatter parser now lives canonically in @moxxy/sdk
2
+ // (`parseFrontmatterFile`/`parseFrontmatter`/`renderFrontmatter`), shared with
3
+ // @moxxy/core so the previously-copy-pasted copies can't diverge. This module
4
+ // keeps the historical `parseMdFile`/`ParsedFile` names as thin re-exports.
5
+ // (The plugin already depends on @moxxy/sdk, so this stays leaf-only.)
6
+ import { parseFrontmatterFile, type ParsedFrontmatter } from '@moxxy/sdk';
7
+
8
+ export type ParsedFile = ParsedFrontmatter;
9
+
10
+ export const parseMdFile = parseFrontmatterFile;
11
+
12
+ export { parseFrontmatter, renderFrontmatter } from '@moxxy/sdk';
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asPluginId, asSessionId, asToolCallId, asTurnId, type MoxxyEvent } from '@moxxy/sdk';
3
+ import { recentExchanges, summarizeSession } from './stm.js';
4
+
5
+ const sid = asSessionId('s');
6
+ const tid = asTurnId('t');
7
+ const c1 = asToolCallId('c1');
8
+ const p1 = asPluginId('p1');
9
+
10
+ const stubLog = (events: MoxxyEvent[]) => ({
11
+ length: events.length,
12
+ at: (n: number) => events[n],
13
+ slice: () => events.slice(),
14
+ ofType: <T extends MoxxyEvent['type']>(type: T) =>
15
+ events.filter((e): e is Extract<MoxxyEvent, { type: T }> => e.type === type),
16
+ byTurn: () => events.slice(),
17
+ toJSON: () => events.slice(),
18
+ });
19
+
20
+ const ev = (e: Partial<MoxxyEvent> & Pick<MoxxyEvent, 'type'>, seq: number): MoxxyEvent =>
21
+ ({
22
+ sessionId: sid,
23
+ turnId: tid,
24
+ source: 'system',
25
+ id: `e${seq}` as never,
26
+ seq,
27
+ ts: 0,
28
+ ...e,
29
+ }) as MoxxyEvent;
30
+
31
+ describe('recentExchanges', () => {
32
+ it('returns the most recent N user+assistant messages', () => {
33
+ const log = stubLog([
34
+ ev({ type: 'user_prompt', text: 'hi', source: 'user' }, 0),
35
+ ev({ type: 'assistant_message', content: 'hello', stopReason: 'end_turn', source: 'model' }, 1),
36
+ ev({ type: 'user_prompt', text: 'next', source: 'user' }, 2),
37
+ ]);
38
+ const recent = recentExchanges(log, 2);
39
+ expect(recent.map((r) => r.source)).toEqual(['assistant', 'user']);
40
+ expect(recent[1]!.text).toBe('next');
41
+ });
42
+
43
+ it('ignores non-message events', () => {
44
+ const log = stubLog([
45
+ ev({ type: 'user_prompt', text: 'a', source: 'user' }, 0),
46
+ ev({ type: 'tool_call_requested', callId: c1, name: 'X', input: {}, source: 'model' }, 1),
47
+ ev({ type: 'assistant_message', content: 'done', stopReason: 'end_turn', source: 'model' }, 2),
48
+ ]);
49
+ expect(recentExchanges(log).map((r) => r.source)).toEqual(['user', 'assistant']);
50
+ });
51
+ });
52
+
53
+ describe('summarizeSession', () => {
54
+ it('counts turns, tool calls, errors, skills, plugins', () => {
55
+ const log = stubLog([
56
+ ev({ type: 'user_prompt', text: '', source: 'user' }, 0),
57
+ ev({ type: 'tool_call_requested', callId: c1, name: 'X', input: {}, source: 'model' }, 1),
58
+ ev({ type: 'error', kind: 'fatal', message: '!', source: 'system' }, 2),
59
+ ev({ type: 'plugin_registered', pluginId: p1, name: 'p', version: '1', kind: ['tools'], source: 'system' }, 3),
60
+ ]);
61
+ expect(summarizeSession(log)).toEqual({
62
+ turns: 1,
63
+ toolCalls: 1,
64
+ errors: 1,
65
+ skillsCreated: 0,
66
+ pluginsLoaded: 1,
67
+ });
68
+ });
69
+ });
package/src/stm.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { EventLogReader } from '@moxxy/sdk';
2
+
3
+ export interface SessionFact {
4
+ readonly turnId: string;
5
+ readonly seq: number;
6
+ readonly source: 'user' | 'assistant';
7
+ readonly text: string;
8
+ }
9
+
10
+ /**
11
+ * Short-term memory helpers. STM is the event log itself — these selectors
12
+ * just project a useful view over it.
13
+ */
14
+
15
+ export function recentExchanges(log: EventLogReader, n = 5): ReadonlyArray<SessionFact> {
16
+ const out: SessionFact[] = [];
17
+ for (const e of log.slice()) {
18
+ if (e.type === 'user_prompt') {
19
+ out.push({ turnId: e.turnId, seq: e.seq, source: 'user', text: e.text });
20
+ } else if (e.type === 'assistant_message') {
21
+ out.push({ turnId: e.turnId, seq: e.seq, source: 'assistant', text: e.content });
22
+ }
23
+ }
24
+ return out.slice(-n);
25
+ }
26
+
27
+ export function summarizeSession(log: EventLogReader): {
28
+ turns: number;
29
+ toolCalls: number;
30
+ errors: number;
31
+ skillsCreated: number;
32
+ pluginsLoaded: number;
33
+ } {
34
+ const seenTurns = new Set<string>();
35
+ let toolCalls = 0;
36
+ let errors = 0;
37
+ let skillsCreated = 0;
38
+ let pluginsLoaded = 0;
39
+ for (const e of log.slice()) {
40
+ seenTurns.add(e.turnId);
41
+ if (e.type === 'tool_call_requested') toolCalls += 1;
42
+ if (e.type === 'error') errors += 1;
43
+ if (e.type === 'skill_created') skillsCreated += 1;
44
+ if (e.type === 'plugin_registered') pluginsLoaded += 1;
45
+ if (e.type === 'plugin_unregistered') pluginsLoaded -= 1;
46
+ }
47
+ return { turns: seenTurns.size, toolCalls, errors, skillsCreated, pluginsLoaded };
48
+ }
@@ -0,0 +1,100 @@
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 { listEntries, readEntry, safeRead } from './io.js';
6
+
7
+ let tmp: string;
8
+ beforeEach(async () => {
9
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-io-'));
10
+ });
11
+ afterEach(async () => {
12
+ await fs.rm(tmp, { recursive: true, force: true });
13
+ });
14
+
15
+ async function writeEntry(name: string, type = 'fact'): Promise<void> {
16
+ const md =
17
+ `---\nname: ${name}\ntype: ${type}\ndescription: ${name} desc\n` +
18
+ `createdAt: 2026-01-01T00:00:00Z\nupdatedAt: 2026-01-01T00:00:00Z\n---\n\nbody ${name}\n`;
19
+ await fs.writeFile(path.join(tmp, `${name}.md`), md);
20
+ }
21
+
22
+ describe('listEntries', () => {
23
+ it('returns [] for a missing directory', async () => {
24
+ expect(await listEntries(path.join(tmp, 'nope'))).toEqual([]);
25
+ });
26
+
27
+ it('parses all .md entries, skipping MEMORY.md and non-md files', async () => {
28
+ await writeEntry('a');
29
+ await writeEntry('b');
30
+ await fs.writeFile(path.join(tmp, 'MEMORY.md'), '# index');
31
+ await fs.writeFile(path.join(tmp, 'notes.txt'), 'ignore me');
32
+ const entries = await listEntries(tmp);
33
+ expect(entries.map((e) => e.frontmatter.name).sort()).toEqual(['a', 'b']);
34
+ });
35
+
36
+ it('honors the type filter', async () => {
37
+ await writeEntry('a', 'fact');
38
+ await writeEntry('b', 'preference');
39
+ const facts = await listEntries(tmp, 'fact');
40
+ expect(facts.map((e) => e.frontmatter.name)).toEqual(['a']);
41
+ });
42
+
43
+ it('drops files whose frontmatter fails validation (no throw)', async () => {
44
+ await writeEntry('good');
45
+ await fs.writeFile(path.join(tmp, 'bad.md'), '---\nname: bad\n---\nno type\n');
46
+ const entries = await listEntries(tmp);
47
+ expect(entries.map((e) => e.frontmatter.name)).toEqual(['good']);
48
+ });
49
+
50
+ it('reads entry files concurrently rather than serially', async () => {
51
+ for (let i = 0; i < 6; i++) await writeEntry(`e${i}`);
52
+
53
+ let inFlight = 0;
54
+ let maxInFlight = 0;
55
+ const realRead = fs.readFile.bind(fs);
56
+ const spy = vi
57
+ .spyOn(fs, 'readFile')
58
+ // @ts-expect-error — match the (path, enc) overload we use
59
+ .mockImplementation(async (p: Parameters<typeof realRead>[0], enc) => {
60
+ inFlight++;
61
+ maxInFlight = Math.max(maxInFlight, inFlight);
62
+ await new Promise((r) => setTimeout(r, 10));
63
+ try {
64
+ // @ts-expect-error — forward to the real implementation
65
+ return await realRead(p, enc);
66
+ } finally {
67
+ inFlight--;
68
+ }
69
+ });
70
+
71
+ const entries = await listEntries(tmp);
72
+ spy.mockRestore();
73
+
74
+ // Correctness preserved.
75
+ expect(entries.map((e) => e.frontmatter.name).sort()).toEqual([
76
+ 'e0',
77
+ 'e1',
78
+ 'e2',
79
+ 'e3',
80
+ 'e4',
81
+ 'e5',
82
+ ]);
83
+ // If reads were serialized (await-in-loop) maxInFlight would be 1.
84
+ expect(maxInFlight).toBeGreaterThan(1);
85
+ });
86
+ });
87
+
88
+ describe('safeRead', () => {
89
+ it('agrees with readEntry on the body (both trim surrounding whitespace)', async () => {
90
+ const md =
91
+ `---\nname: x\ntype: fact\ndescription: x desc\n` +
92
+ `createdAt: 2026-01-01T00:00:00Z\nupdatedAt: 2026-01-01T00:00:00Z\n---\n\n\n padded body \n\n`;
93
+ const file = path.join(tmp, 'x.md');
94
+ await fs.writeFile(file, md);
95
+ const viaSafe = await safeRead(file);
96
+ const viaEntry = await readEntry(file);
97
+ expect(viaSafe?.body).toBe('padded body');
98
+ expect(viaSafe?.body).toBe(viaEntry?.body);
99
+ });
100
+ });
@@ -0,0 +1,138 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { writeFileAtomic } from '@moxxy/sdk/server';
4
+ import { parseMdFile } from '../parse.js';
5
+ import {
6
+ memoryFrontmatterSchema,
7
+ type MemoryEntry,
8
+ type MemoryFrontmatter,
9
+ type MemoryType,
10
+ } from './types.js';
11
+
12
+ export async function safeRead(
13
+ filePath: string,
14
+ ): Promise<{ frontmatter: MemoryFrontmatter; body: string } | null> {
15
+ try {
16
+ const raw = await fs.readFile(filePath, 'utf8');
17
+ const parsed = parseMdFile(raw);
18
+ const result = memoryFrontmatterSchema.safeParse(parsed.frontmatter);
19
+ if (!result.success) return null;
20
+ // Trim the body so safeRead agrees with readEntry/listEntries on the same
21
+ // file — they both `.trim()`. safeRead's only caller (writeEntry) reads just
22
+ // createdAt and discards the body, so this is behavior-preserving today; it
23
+ // removes the latent trap where a future body consumer would see different
24
+ // whitespace depending on which read path it used.
25
+ return { frontmatter: result.data, body: parsed.body.trim() };
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function isEnoent(err: unknown): boolean {
32
+ return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
33
+ }
34
+
35
+ /**
36
+ * Cap on simultaneously-open file descriptors when reading a memory dir. The
37
+ * soft cap on entry count is warn-only, so a long-lived dir can hold thousands
38
+ * of files; an unbounded `Promise.all(readFile)` over all of them would open
39
+ * thousands of fds at once and can hit the OS limit (EMFILE) — failing every
40
+ * list/recall. Batching keeps the common case fast but bounds fd pressure.
41
+ */
42
+ const READ_CONCURRENCY = 32;
43
+
44
+ export async function listEntries(
45
+ dir: string,
46
+ filterType?: MemoryType,
47
+ ): Promise<ReadonlyArray<MemoryEntry>> {
48
+ let names: import('node:fs').Dirent[];
49
+ try {
50
+ names = await fs.readdir(dir, { withFileTypes: true });
51
+ } catch (err) {
52
+ if (isEnoent(err)) return [];
53
+ throw err;
54
+ }
55
+ // recall() and rows() both call this on the hot path; with up to
56
+ // DEFAULT_MAX_MEMORIES (500) entries, reading+parsing each file serially is
57
+ // 500 strictly-ordered disk round-trips. The reads are independent, so fan
58
+ // them out concurrently (bounded by READ_CONCURRENCY) and preserve dirent
59
+ // order in the result.
60
+ const candidates = names.filter(
61
+ (d) => d.isFile() && d.name.endsWith('.md') && d.name !== 'MEMORY.md',
62
+ );
63
+ const readOne = async (dirent: import('node:fs').Dirent): Promise<MemoryEntry | null> => {
64
+ const filePath = path.join(dir, dirent.name);
65
+ let raw: string;
66
+ try {
67
+ raw = await fs.readFile(filePath, 'utf8');
68
+ } catch (err) {
69
+ // One unreadable file (deleted between readdir and readFile, transient
70
+ // permission error) must not fail the whole list. Drop it, surface
71
+ // non-ENOENT causes for diagnosis.
72
+ if (!isEnoent(err)) {
73
+ console.warn(`[plugin-memory] skipping unreadable memory file ${filePath}: ${String(err)}`);
74
+ }
75
+ return null;
76
+ }
77
+ const md = parseMdFile(raw);
78
+ const result = memoryFrontmatterSchema.safeParse(md.frontmatter);
79
+ if (!result.success) {
80
+ // A malformed entry silently vanishing from recall/index is a data-loss
81
+ // surprise; leave a breadcrumb so it's diagnosable.
82
+ console.warn(`[plugin-memory] ignoring memory file with invalid frontmatter: ${filePath}`);
83
+ return null;
84
+ }
85
+ if (filterType && result.data.type !== filterType) return null;
86
+ return {
87
+ frontmatter: result.data,
88
+ body: md.body.trim(),
89
+ path: filePath,
90
+ } satisfies MemoryEntry;
91
+ };
92
+ const parsed: Array<MemoryEntry | null> = [];
93
+ for (let i = 0; i < candidates.length; i += READ_CONCURRENCY) {
94
+ const batch = candidates.slice(i, i + READ_CONCURRENCY);
95
+ parsed.push(...(await Promise.all(batch.map(readOne))));
96
+ }
97
+ return parsed.filter((e): e is MemoryEntry => e !== null);
98
+ }
99
+
100
+ export async function readEntry(filePath: string): Promise<MemoryEntry | null> {
101
+ try {
102
+ const raw = await fs.readFile(filePath, 'utf8');
103
+ const parsed = parseMdFile(raw);
104
+ const result = memoryFrontmatterSchema.safeParse(parsed.frontmatter);
105
+ if (!result.success) return null;
106
+ return { frontmatter: result.data, body: parsed.body.trim(), path: filePath };
107
+ } catch (err) {
108
+ if (isEnoent(err)) return null;
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /** What the MEMORY.md index needs per entry — no body, so the incremental
114
+ * index cache in MemoryStore can feed it without re-reading entry files. */
115
+ export type IndexRow = Pick<MemoryEntry, 'frontmatter' | 'path'>;
116
+
117
+ export async function writeIndex(
118
+ dir: string,
119
+ entries: ReadonlyArray<IndexRow>,
120
+ ): Promise<void> {
121
+ const lines = ['# Memory index', ''];
122
+ const byType = new Map<MemoryType, IndexRow[]>();
123
+ for (const e of entries) {
124
+ const list = byType.get(e.frontmatter.type) ?? [];
125
+ list.push(e);
126
+ byType.set(e.frontmatter.type, list);
127
+ }
128
+ for (const t of ['fact', 'preference', 'project', 'reference'] as const) {
129
+ const items = byType.get(t);
130
+ if (!items || items.length === 0) continue;
131
+ lines.push(`## ${t}`);
132
+ for (const item of items) {
133
+ lines.push(`- [${item.frontmatter.name}](${path.basename(item.path)}) — ${item.frontmatter.description}`);
134
+ }
135
+ lines.push('');
136
+ }
137
+ await writeFileAtomic(path.join(dir, 'MEMORY.md'), lines.join('\n'));
138
+ }