@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,535 @@
1
+ import {
2
+ z,
3
+ defineTool,
4
+ definePlugin,
5
+ type LifecycleHooks,
6
+ type LLMProvider,
7
+ type Plugin,
8
+ } from '@moxxy/sdk';
9
+ import type { MemoryStore} from './store.js';
10
+ import { memoryTypeSchema, type MemoryEntry, type MemoryType } from './store.js';
11
+
12
+ const SYSTEM_PROMPT = `You are consolidating overlapping long-term memory entries.
13
+
14
+ You receive a cluster of 2 or more entries that touch the same topic. Produce a single canonical entry that:
15
+ - preserves every distinct fact from the cluster (no information loss)
16
+ - removes duplication
17
+ - keeps the body under 30 lines
18
+ - picks the most informative description (one sentence, ≤120 chars)
19
+ - inherits a single 'type' from the cluster (vote: fact / preference / project / reference)
20
+
21
+ Output ONLY a JSON object with keys: { "name", "type", "description", "body", "tags" }
22
+ where name is a kebab-case slug (use the most descriptive name from the cluster).`;
23
+
24
+ const consolidatedSchema = z.object({
25
+ name: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/),
26
+ type: memoryTypeSchema,
27
+ description: z.string().min(1).max(280),
28
+ body: z.string().min(1).max(4000),
29
+ tags: z.array(z.string().min(1)).optional(),
30
+ });
31
+
32
+ export interface ConsolidatePlan {
33
+ readonly clusters: ReadonlyArray<{
34
+ readonly key: string;
35
+ readonly members: ReadonlyArray<string>;
36
+ }>;
37
+ readonly stable: ReadonlyArray<string>;
38
+ }
39
+
40
+ /**
41
+ * Group memories into clusters by shared tag, sharing 2+ tokens in the
42
+ * description, or both. Each cluster has at least 2 members; singletons are
43
+ * reported as `stable` and left alone.
44
+ */
45
+ export function planConsolidation(
46
+ entries: ReadonlyArray<MemoryEntry>,
47
+ opts: { tag?: string } = {},
48
+ ): ConsolidatePlan {
49
+ const filtered = opts.tag
50
+ ? entries.filter((e) => (e.frontmatter.tags ?? []).includes(opts.tag!))
51
+ : entries;
52
+ if (filtered.length < 2) {
53
+ return { clusters: [], stable: filtered.map((e) => e.frontmatter.name) };
54
+ }
55
+
56
+ // Primary cluster key: a shared tag. If no tag, fall back to
57
+ // overlap of >=2 tokens in description.
58
+ const byTag = new Map<string, MemoryEntry[]>();
59
+ const tagless: MemoryEntry[] = [];
60
+ for (const e of filtered) {
61
+ const tags = e.frontmatter.tags ?? [];
62
+ if (tags.length === 0) {
63
+ tagless.push(e);
64
+ } else {
65
+ // Use the first tag as the cluster key (deterministic, simple)
66
+ const key = tags[0]!;
67
+ const list = byTag.get(key) ?? [];
68
+ list.push(e);
69
+ byTag.set(key, list);
70
+ }
71
+ }
72
+
73
+ // Description-overlap clustering for the tagless tail. O(n²) but acceptable
74
+ // for the scale we expect (hundreds of memories).
75
+ const descClusters: Array<{ key: string; members: MemoryEntry[] }> = [];
76
+ for (const entry of tagless) {
77
+ const tokens = new Set(
78
+ tokenize(entry.frontmatter.description).concat(tokenize(entry.frontmatter.name)),
79
+ );
80
+ let placed = false;
81
+ for (const cluster of descClusters) {
82
+ const clusterTokens = new Set(cluster.members.flatMap((m) => tokenize(m.frontmatter.description)));
83
+ const overlap = [...tokens].filter((t) => clusterTokens.has(t)).length;
84
+ if (overlap >= 2) {
85
+ cluster.members.push(entry);
86
+ placed = true;
87
+ break;
88
+ }
89
+ }
90
+ if (!placed) {
91
+ descClusters.push({ key: `desc:${entry.frontmatter.name}`, members: [entry] });
92
+ }
93
+ }
94
+
95
+ const clusters: Array<{ key: string; members: ReadonlyArray<string> }> = [];
96
+ const stable: string[] = [];
97
+
98
+ for (const [key, members] of byTag) {
99
+ if (members.length >= 2) {
100
+ clusters.push({ key: `tag:${key}`, members: members.map((m) => m.frontmatter.name) });
101
+ } else {
102
+ stable.push(members[0]!.frontmatter.name);
103
+ }
104
+ }
105
+ for (const cluster of descClusters) {
106
+ if (cluster.members.length >= 2) {
107
+ clusters.push({ key: cluster.key, members: cluster.members.map((m) => m.frontmatter.name) });
108
+ } else {
109
+ stable.push(cluster.members[0]!.frontmatter.name);
110
+ }
111
+ }
112
+
113
+ return { clusters, stable };
114
+ }
115
+
116
+ function tokenize(text: string): string[] {
117
+ return text
118
+ .toLowerCase()
119
+ .split(/[^a-z0-9_-]+/)
120
+ .filter((t) => t.length >= 3);
121
+ }
122
+
123
+ export interface ConsolidateOptions {
124
+ readonly tag?: string;
125
+ readonly dryRun?: boolean;
126
+ /**
127
+ * Upper bound (ms) on each per-cluster provider stream. A hung/stalled
128
+ * provider must not let `memory_consolidate` block forever — without this the
129
+ * `for await` loop has no timeout and waits indefinitely for the next event.
130
+ * The timeout aborts that one stream; the cluster is recorded as not-merged.
131
+ * Default {@link DEFAULT_CONSOLIDATE_TIMEOUT_MS}. Set to 0 to disable.
132
+ */
133
+ readonly timeoutMs?: number;
134
+ /** Optional caller abort (e.g. session shutdown); combined with the timeout. */
135
+ readonly signal?: AbortSignal;
136
+ }
137
+
138
+ /** Default per-cluster provider-stream timeout for {@link consolidateMemory}. */
139
+ export const DEFAULT_CONSOLIDATE_TIMEOUT_MS = 60_000;
140
+
141
+ export interface ConsolidationOutcome {
142
+ readonly clusters: ReadonlyArray<{
143
+ readonly key: string;
144
+ readonly merged: ReadonlyArray<string>;
145
+ readonly into: string | null;
146
+ readonly dryRun: boolean;
147
+ }>;
148
+ readonly stable: ReadonlyArray<string>;
149
+ }
150
+
151
+ export async function consolidateMemory(
152
+ store: MemoryStore,
153
+ provider: LLMProvider,
154
+ opts: ConsolidateOptions = {},
155
+ ): Promise<ConsolidationOutcome> {
156
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_CONSOLIDATE_TIMEOUT_MS;
157
+ const all = await store.list();
158
+ const plan = planConsolidation(all, { tag: opts.tag });
159
+ const byName = new Map(all.map((e) => [e.frontmatter.name, e]));
160
+ // Names produced by merges earlier in THIS run. They aren't in `byName`
161
+ // (which is a snapshot of the initial store), so without tracking them a
162
+ // later cluster could pick the same name and silently clobber an
163
+ // already-consolidated entry — the very data loss the collision guard exists
164
+ // to prevent.
165
+ const produced = new Set<string>();
166
+ const outcomes: Array<{
167
+ key: string;
168
+ merged: ReadonlyArray<string>;
169
+ into: string | null;
170
+ dryRun: boolean;
171
+ }> = [];
172
+
173
+ for (const cluster of plan.clusters) {
174
+ const members = cluster.members.map((n) => byName.get(n)).filter((e): e is MemoryEntry => Boolean(e));
175
+ if (members.length < 2) continue;
176
+
177
+ if (opts.dryRun) {
178
+ outcomes.push({ key: cluster.key, merged: cluster.members, into: null, dryRun: true });
179
+ continue;
180
+ }
181
+
182
+ const prompt = members
183
+ .map(
184
+ (m, i) =>
185
+ `[${i + 1}] name: ${m.frontmatter.name}\ntype: ${m.frontmatter.type}\ndescription: ${m.frontmatter.description}\nbody: ${m.body}`,
186
+ )
187
+ .join('\n\n');
188
+
189
+ // Bound the per-cluster stream: a hung provider must not stall consolidation
190
+ // forever. Combine an optional caller signal with a timeout abort, and clear
191
+ // the timer in finally so it never leaks past this cluster. We RACE each
192
+ // iterator step against the abort signal so even a provider that ignores
193
+ // `req.signal` (and never yields again) can't hang us — we abandon its
194
+ // iterator and move on. The cluster is recorded as not-merged.
195
+ const signal = combineAbort(opts.signal, timeoutMs);
196
+ let response = '';
197
+ let aborted = false;
198
+ const iterable = provider.stream({
199
+ model: provider.models[0]?.id ?? 'unknown',
200
+ system: SYSTEM_PROMPT,
201
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
202
+ maxTokens: 2000,
203
+ ...(signal.signal ? { signal: signal.signal } : {}),
204
+ });
205
+ const iterator = iterable[Symbol.asyncIterator]();
206
+ try {
207
+ for (;;) {
208
+ const step = await raceAbort(iterator.next(), signal.signal);
209
+ if (step === ABORTED) {
210
+ aborted = true;
211
+ break;
212
+ }
213
+ if (step.done) break;
214
+ const event = step.value;
215
+ if (event.type === 'text_delta') response += event.delta;
216
+ if (event.type === 'error') {
217
+ throw new Error(`consolidate: provider error: ${event.message}`);
218
+ }
219
+ }
220
+ } catch (err) {
221
+ // A provider that honors the signal by throwing also degrades cleanly.
222
+ if (signal.signal?.aborted || isAbortError(err)) {
223
+ aborted = true;
224
+ } else {
225
+ throw err;
226
+ }
227
+ } finally {
228
+ // Abandon a non-cooperative iterator so it can release resources; ignore
229
+ // any return() error — we're already moving on.
230
+ if (aborted) void iterator.return?.(undefined).catch(() => {});
231
+ signal.dispose();
232
+ }
233
+ if (aborted) {
234
+ console.warn(`[plugin-memory] consolidate: provider stream aborted for cluster ${cluster.key} (timeout ${timeoutMs}ms)`);
235
+ outcomes.push({ key: cluster.key, merged: cluster.members, into: null, dryRun: false });
236
+ continue;
237
+ }
238
+
239
+ const extracted = extractJson(response);
240
+ const parsed = consolidatedSchema.parse(extracted);
241
+
242
+ // Guard: if the LLM picked a name that already exists OUTSIDE this
243
+ // cluster, writing it would silently clobber an unrelated memory. Skip
244
+ // the merge and record the cluster as not-merged rather than destroy
245
+ // data. `byName` is a snapshot taken before any provider streaming, so it
246
+ // can be stale by now — re-read the live entry from disk to also catch an
247
+ // entry created by a concurrent writer since the snapshot.
248
+ const clusterNames = new Set(cluster.members);
249
+ const liveCollision = !clusterNames.has(parsed.name) && (await store.get(parsed.name)) !== null;
250
+ if (
251
+ (byName.has(parsed.name) || produced.has(parsed.name) || liveCollision) &&
252
+ !clusterNames.has(parsed.name)
253
+ ) {
254
+ outcomes.push({
255
+ key: cluster.key,
256
+ merged: cluster.members,
257
+ into: null,
258
+ dryRun: false,
259
+ });
260
+ continue;
261
+ }
262
+
263
+ await store.save({
264
+ name: parsed.name,
265
+ type: parsed.type as MemoryType,
266
+ description: parsed.description,
267
+ body: parsed.body,
268
+ ...(parsed.tags ? { tags: parsed.tags } : {}),
269
+ });
270
+ produced.add(parsed.name);
271
+ // Delete merged entries except the one we just saved (if it overlaps).
272
+ for (const member of members) {
273
+ if (member.frontmatter.name !== parsed.name) {
274
+ await store.forget(member.frontmatter.name);
275
+ }
276
+ }
277
+
278
+ outcomes.push({
279
+ key: cluster.key,
280
+ merged: cluster.members,
281
+ into: parsed.name,
282
+ dryRun: false,
283
+ });
284
+ }
285
+
286
+ return { clusters: outcomes, stable: plan.stable };
287
+ }
288
+
289
+ /**
290
+ * Combine an optional caller signal with a timeout into one AbortSignal,
291
+ * returning a `dispose()` that clears the timer so it can't fire (and keep the
292
+ * event loop alive) after the stream finishes. Returns `{ signal: undefined }`
293
+ * when neither bound applies, so we don't pass an always-open signal needlessly.
294
+ */
295
+ function combineAbort(
296
+ caller: AbortSignal | undefined,
297
+ timeoutMs: number,
298
+ ): { signal: AbortSignal | undefined; dispose: () => void } {
299
+ const useTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0;
300
+ if (!caller && !useTimeout) return { signal: undefined, dispose: () => {} };
301
+ const controller = new AbortController();
302
+ const onAbort = () => controller.abort(caller?.reason);
303
+ if (caller) {
304
+ if (caller.aborted) controller.abort(caller.reason);
305
+ else caller.addEventListener('abort', onAbort, { once: true });
306
+ }
307
+ let timer: ReturnType<typeof setTimeout> | undefined;
308
+ if (useTimeout && !controller.signal.aborted) {
309
+ timer = setTimeout(() => controller.abort(new Error('consolidate: provider stream timed out')), timeoutMs);
310
+ // Don't keep the process alive solely for this watchdog.
311
+ timer.unref?.();
312
+ }
313
+ return {
314
+ signal: controller.signal,
315
+ dispose: () => {
316
+ if (timer) clearTimeout(timer);
317
+ caller?.removeEventListener('abort', onAbort);
318
+ },
319
+ };
320
+ }
321
+
322
+ /** Sentinel resolved by {@link raceAbort} when the signal fires first. */
323
+ const ABORTED = Symbol('aborted');
324
+
325
+ /**
326
+ * Race a pending step against an abort signal. Returns the step's result if it
327
+ * settles first, or {@link ABORTED} if the signal fires first — so a provider
328
+ * that ignores `req.signal` and never yields again can't stall the loop. The
329
+ * abandoned `next()` promise is left to settle on its own (its rejection, if
330
+ * any, is swallowed); the caller stops consuming the iterator.
331
+ */
332
+ function raceAbort<T>(
333
+ step: Promise<T>,
334
+ signal: AbortSignal | undefined,
335
+ ): Promise<T | typeof ABORTED> {
336
+ if (!signal) return step;
337
+ if (signal.aborted) {
338
+ void step.catch(() => {});
339
+ return Promise.resolve(ABORTED);
340
+ }
341
+ return new Promise<T | typeof ABORTED>((resolve, reject) => {
342
+ const onAbort = () => {
343
+ void step.catch(() => {});
344
+ resolve(ABORTED);
345
+ };
346
+ signal.addEventListener('abort', onAbort, { once: true });
347
+ step.then(
348
+ (v) => {
349
+ signal.removeEventListener('abort', onAbort);
350
+ resolve(v);
351
+ },
352
+ (e) => {
353
+ signal.removeEventListener('abort', onAbort);
354
+ reject(e);
355
+ },
356
+ );
357
+ });
358
+ }
359
+
360
+ function isAbortError(err: unknown): boolean {
361
+ return (
362
+ (err instanceof Error && err.name === 'AbortError') ||
363
+ (typeof err === 'object' && err !== null && (err as { name?: string }).name === 'AbortError')
364
+ );
365
+ }
366
+
367
+ // Upper bound on the JSON span we'll JSON.parse from a model response. The
368
+ // consolidated entry is tiny (description ≤280, body ≤4000), so a span far
369
+ // past this is a malformed/hostile response, not a real entry — refuse it
370
+ // before handing an unbounded string to JSON.parse.
371
+ const MAX_JSON_SPAN = 64 * 1024;
372
+
373
+ function extractJson(text: string): unknown {
374
+ // Take the span from the first `{` to the last `}` in the response. Some
375
+ // providers wrap the object in ```json ... ```, which we strip first.
376
+ const fenced = /```(?:json)?\n?([\s\S]*?)```/.exec(text);
377
+ const candidate = fenced ? fenced[1]! : text;
378
+ const start = candidate.indexOf('{');
379
+ const end = candidate.lastIndexOf('}');
380
+ // `end <= start` (e.g. a stray `}` before the first `{`) means there's no
381
+ // well-formed object span; surface the intended message instead of letting
382
+ // slice() yield garbage that JSON.parse fails on with an opaque error.
383
+ if (start === -1 || end <= start) throw new Error('consolidate: model returned no JSON object');
384
+ const span = candidate.slice(start, end + 1);
385
+ if (span.length > MAX_JSON_SPAN) {
386
+ throw new Error('consolidate: model JSON object too large to parse');
387
+ }
388
+ return JSON.parse(span);
389
+ }
390
+
391
+ export interface BuildMemoryConsolidateOptions {
392
+ /**
393
+ * When memory.list().length crosses this number, the plugin appends a hint
394
+ * to the next provider request's system prompt nudging the agent to call
395
+ * `memory_consolidate`. Default: 30. Set to 0 to disable the nudge.
396
+ */
397
+ readonly autoNudgeThreshold?: number;
398
+ }
399
+
400
+ /** Minimal view of the provider registry — just what consolidation needs (the
401
+ * active provider at call time). The host publishes the full registry under
402
+ * `'providers'`; this avoids importing `@moxxy/core`'s concrete type. */
403
+ interface ActiveProviderSource {
404
+ getActive(): LLMProvider | null;
405
+ }
406
+
407
+ export function buildMemoryConsolidatePlugin(
408
+ store: MemoryStore,
409
+ getProvider: () => LLMProvider,
410
+ opts: BuildMemoryConsolidateOptions = {},
411
+ ): Plugin {
412
+ // Host-injected store + provider accessor (available immediately).
413
+ return makeMemoryConsolidatePlugin(() => store, getProvider, opts);
414
+ }
415
+
416
+ /**
417
+ * Discovery-loadable default export: resolves the long-term store (`'memory'`,
418
+ * published by @moxxy/plugin-memory) and the active provider (via the `'providers'`
419
+ * registry) from the inter-plugin service registry in `onInit`, so it no longer
420
+ * needs the `(store, getProvider)` closure. @moxxy/plugin-memory is registered
421
+ * first, so its onInit publishes the store before this one resolves it.
422
+ */
423
+ export const memoryConsolidatePlugin: Plugin = (() => {
424
+ let store: MemoryStore | null = null;
425
+ let providers: ActiveProviderSource | null = null;
426
+ const onInit: LifecycleHooks['onInit'] = (ctx) => {
427
+ store = ctx.services.get<MemoryStore>('memory') ?? null;
428
+ providers = ctx.services.get<ActiveProviderSource>('providers') ?? null;
429
+ };
430
+ return makeMemoryConsolidatePlugin(
431
+ () => {
432
+ if (!store) {
433
+ throw new Error(
434
+ '@moxxy/memory-consolidate: the "memory" service is unavailable — @moxxy/plugin-memory must load first',
435
+ );
436
+ }
437
+ return store;
438
+ },
439
+ () => {
440
+ const p = providers?.getActive();
441
+ if (!p) {
442
+ throw new Error('@moxxy/memory-consolidate: no active provider to consolidate with');
443
+ }
444
+ return p;
445
+ },
446
+ {},
447
+ onInit,
448
+ );
449
+ })();
450
+
451
+ function makeMemoryConsolidatePlugin(
452
+ getStore: () => MemoryStore,
453
+ getProvider: () => LLMProvider,
454
+ opts: BuildMemoryConsolidateOptions = {},
455
+ extraOnInit?: LifecycleHooks['onInit'],
456
+ ): Plugin {
457
+ const threshold = opts.autoNudgeThreshold ?? 30;
458
+ let nudged = false;
459
+
460
+ const nudgeHook: LifecycleHooks =
461
+ threshold > 0
462
+ ? {
463
+ onBeforeProviderCall: async (req) => {
464
+ if (nudged) return; // one nudge per session
465
+ // Count only — capStatus() serves the in-memory index-row cache,
466
+ // so a below-threshold store doesn't re-scan + re-parse every
467
+ // memory file on every single provider call for the whole session
468
+ // (store.list() did a full disk read+parse each time).
469
+ const count = (await getStore().capStatus()).count;
470
+ if (count <= threshold) return;
471
+ nudged = true;
472
+ const hint =
473
+ `\n\n[memory note] long-term memory has ${count} entries (threshold: ${threshold}). ` +
474
+ `consider running \`memory_consolidate\` when there's a natural break to merge overlapping entries.`;
475
+ return { ...req, system: (req.system ?? '') + hint };
476
+ },
477
+ }
478
+ : {};
479
+
480
+ const hooks: LifecycleHooks = extraOnInit
481
+ ? { ...nudgeHook, onInit: extraOnInit }
482
+ : nudgeHook;
483
+ return definePlugin({
484
+ name: '@moxxy/memory-consolidate',
485
+ version: '0.0.0',
486
+ hooks,
487
+ tools: [
488
+ defineTool({
489
+ name: 'memory_consolidate',
490
+ description:
491
+ 'Merge overlapping long-term memory entries into single canonical ones. ' +
492
+ 'Clusters by shared tag or shared tokens in name+description. ' +
493
+ 'Use dryRun=true to preview the plan without modifying anything.',
494
+ inputSchema: z.object({
495
+ tag: z.string().optional(),
496
+ dryRun: z.boolean().optional().default(false),
497
+ }),
498
+ permission: { action: 'prompt' },
499
+ isolation: {
500
+ capabilities: {
501
+ fs: { read: ['~/.moxxy/memory/**'], write: ['~/.moxxy/memory/**'] },
502
+ // Each cluster merge streams from the active LLMProvider; the
503
+ // inproc isolator can't pin the host (provider-config dependent).
504
+ net: { mode: 'any' },
505
+ // Many clusters × the 60s per-cluster provider-stream bound.
506
+ timeMs: 600_000,
507
+ },
508
+ },
509
+ handler: async ({ tag, dryRun }) => {
510
+ const outcome = await consolidateMemory(getStore(), getProvider(), {
511
+ ...(tag ? { tag } : {}),
512
+ ...(dryRun !== undefined ? { dryRun } : {}),
513
+ });
514
+ return outcome;
515
+ },
516
+ }),
517
+ defineTool({
518
+ name: 'memory_consolidate_plan',
519
+ description: 'Return the consolidation plan (clusters that would be merged) without invoking the model.',
520
+ inputSchema: z.object({ tag: z.string().optional() }),
521
+ isolation: {
522
+ capabilities: {
523
+ fs: { read: ['~/.moxxy/memory/**'] },
524
+ net: { mode: 'none' },
525
+ timeMs: 15_000,
526
+ },
527
+ },
528
+ handler: async ({ tag }) => {
529
+ const all = await getStore().list();
530
+ return planConsolidation(all, { ...(tag ? { tag } : {}) });
531
+ },
532
+ }),
533
+ ],
534
+ });
535
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ServiceRegistry } from '@moxxy/sdk';
3
+ import { memoryConsolidatePlugin } from './index.js';
4
+
5
+ /**
6
+ * The discovery-loadable default export resolves the long-term store ('memory',
7
+ * published by the memory plugin) + the active provider (via 'providers') from
8
+ * the inter-plugin service registry in onInit, instead of a `(store, getProvider)`
9
+ * closure. (Behaviour with an injected store is covered by consolidate.test.)
10
+ */
11
+ describe('memoryConsolidatePlugin (discovery-loadable)', () => {
12
+ it('exposes the consolidate tools + an onInit hook (alongside the nudge hook)', () => {
13
+ expect(memoryConsolidatePlugin.tools?.map((t) => t.name).sort()).toEqual([
14
+ 'memory_consolidate',
15
+ 'memory_consolidate_plan',
16
+ ]);
17
+ expect(typeof memoryConsolidatePlugin.hooks?.onInit).toBe('function');
18
+ expect(typeof memoryConsolidatePlugin.hooks?.onBeforeProviderCall).toBe('function');
19
+ });
20
+
21
+ it('onInit resolves the memory + providers services from the registry', () => {
22
+ const get = vi.fn(() => ({ getActive: () => null }));
23
+ const services = {
24
+ get,
25
+ require: () => ({}),
26
+ has: () => true,
27
+ register: () => {},
28
+ } as unknown as ServiceRegistry;
29
+ memoryConsolidatePlugin.hooks!.onInit!({ services } as never);
30
+ expect(get).toHaveBeenCalledWith('memory');
31
+ expect(get).toHaveBeenCalledWith('providers');
32
+ });
33
+ });