@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,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
|
+
});
|