@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,328 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import type { LLMProvider, ProviderEvent } from '@moxxy/sdk';
|
|
6
|
+
import { MemoryStore } from './store.js';
|
|
7
|
+
import { consolidateMemory, planConsolidation } from './consolidate.js';
|
|
8
|
+
|
|
9
|
+
let tmp: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-cons-'));
|
|
13
|
+
});
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const fakeProvider = (jsonReply: string): LLMProvider => ({
|
|
19
|
+
name: 'fake',
|
|
20
|
+
models: [{ id: 'fake', contextWindow: 1000, maxOutputTokens: 1000, supportsTools: false, supportsStreaming: true }],
|
|
21
|
+
async *stream(): AsyncIterable<ProviderEvent> {
|
|
22
|
+
yield { type: 'message_start', model: 'fake' };
|
|
23
|
+
yield { type: 'text_delta', delta: jsonReply };
|
|
24
|
+
yield { type: 'message_end', stopReason: 'end_turn' };
|
|
25
|
+
},
|
|
26
|
+
async countTokens() {
|
|
27
|
+
return 0;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// A provider whose stream hangs forever after the first delta (or from the
|
|
32
|
+
// start) UNLESS the request signal aborts — models the worst case: a stalled
|
|
33
|
+
// network where no further event ever arrives.
|
|
34
|
+
const hangingProvider = (): LLMProvider => ({
|
|
35
|
+
name: 'hang',
|
|
36
|
+
models: [{ id: 'hang', contextWindow: 1000, maxOutputTokens: 1000, supportsTools: false, supportsStreaming: true }],
|
|
37
|
+
async *stream(req): AsyncIterable<ProviderEvent> {
|
|
38
|
+
yield { type: 'message_start', model: 'hang' };
|
|
39
|
+
// Block until the caller's abort signal fires; never yields message_end.
|
|
40
|
+
await new Promise<void>((resolve, reject) => {
|
|
41
|
+
const sig = req.signal;
|
|
42
|
+
if (!sig) return; // no signal → would hang forever (the bug we guard against)
|
|
43
|
+
if (sig.aborted) return reject(new DOMException('aborted', 'AbortError'));
|
|
44
|
+
sig.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
async countTokens() {
|
|
48
|
+
return 0;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// A provider that IGNORES req.signal entirely and simply never yields again —
|
|
53
|
+
// the truly hostile case. consolidateMemory must still bound it via the
|
|
54
|
+
// iterator-step race, not wait on the generator's next() forever.
|
|
55
|
+
const deafProvider = (): LLMProvider => ({
|
|
56
|
+
name: 'deaf',
|
|
57
|
+
models: [{ id: 'deaf', contextWindow: 1000, maxOutputTokens: 1000, supportsTools: false, supportsStreaming: true }],
|
|
58
|
+
async *stream(): AsyncIterable<ProviderEvent> {
|
|
59
|
+
yield { type: 'message_start', model: 'deaf' };
|
|
60
|
+
// Hang forever, ignoring any abort signal.
|
|
61
|
+
await new Promise<void>(() => {});
|
|
62
|
+
yield { type: 'message_end', stopReason: 'end_turn' };
|
|
63
|
+
},
|
|
64
|
+
async countTokens() {
|
|
65
|
+
return 0;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('planConsolidation', () => {
|
|
70
|
+
it('groups entries by shared first tag', () => {
|
|
71
|
+
const entries = [
|
|
72
|
+
mkEntry('a', { tags: ['api'] }),
|
|
73
|
+
mkEntry('b', { tags: ['api'] }),
|
|
74
|
+
mkEntry('c', { tags: ['db'] }),
|
|
75
|
+
];
|
|
76
|
+
const plan = planConsolidation(entries);
|
|
77
|
+
const apiCluster = plan.clusters.find((c) => c.key === 'tag:api');
|
|
78
|
+
expect(apiCluster?.members.sort()).toEqual(['a', 'b']);
|
|
79
|
+
expect(plan.stable).toContain('c');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('handles tagless entries via description token overlap', () => {
|
|
83
|
+
const entries = [
|
|
84
|
+
mkEntry('a', { description: 'team prefers tRPC over REST endpoints' }),
|
|
85
|
+
mkEntry('b', { description: 'tRPC endpoints style for the team' }),
|
|
86
|
+
mkEntry('c', { description: 'production uses Postgres 16' }),
|
|
87
|
+
];
|
|
88
|
+
const plan = planConsolidation(entries);
|
|
89
|
+
// a and b should cluster on shared tokens
|
|
90
|
+
const overlapCluster = plan.clusters.find((c) => c.members.includes('a') && c.members.includes('b'));
|
|
91
|
+
expect(overlapCluster).toBeDefined();
|
|
92
|
+
expect(plan.stable).toContain('c');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('singletons go to `stable`', () => {
|
|
96
|
+
const entries = [mkEntry('lonely', { tags: ['solo'] })];
|
|
97
|
+
const plan = planConsolidation(entries);
|
|
98
|
+
expect(plan.clusters).toEqual([]);
|
|
99
|
+
expect(plan.stable).toEqual(['lonely']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('filters by tag when opts.tag is set', () => {
|
|
103
|
+
const entries = [
|
|
104
|
+
mkEntry('a', { tags: ['api'] }),
|
|
105
|
+
mkEntry('b', { tags: ['api'] }),
|
|
106
|
+
mkEntry('c', { tags: ['db'] }),
|
|
107
|
+
mkEntry('d', { tags: ['db'] }),
|
|
108
|
+
];
|
|
109
|
+
const plan = planConsolidation(entries, { tag: 'api' });
|
|
110
|
+
expect(plan.clusters).toHaveLength(1);
|
|
111
|
+
expect(plan.clusters[0]!.members.sort()).toEqual(['a', 'b']);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('consolidateMemory', () => {
|
|
116
|
+
it('dryRun: true reports the plan without modifying anything', async () => {
|
|
117
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
118
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'aaa', tags: ['api'] });
|
|
119
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'bbb', tags: ['api'] });
|
|
120
|
+
|
|
121
|
+
const before = await store.list();
|
|
122
|
+
const outcome = await consolidateMemory(store, fakeProvider(''), { dryRun: true });
|
|
123
|
+
expect(outcome.clusters[0]?.dryRun).toBe(true);
|
|
124
|
+
expect(outcome.clusters[0]?.into).toBeNull();
|
|
125
|
+
const after = await store.list();
|
|
126
|
+
expect(after.length).toBe(before.length);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('without dryRun: writes the consolidated entry and deletes the merged ones', async () => {
|
|
130
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
131
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'aaa', tags: ['api'] });
|
|
132
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'bbb', tags: ['api'] });
|
|
133
|
+
|
|
134
|
+
const jsonReply = JSON.stringify({
|
|
135
|
+
name: 'a-and-b-merged',
|
|
136
|
+
type: 'fact',
|
|
137
|
+
description: 'Merged: A and B.',
|
|
138
|
+
body: 'aaa\nbbb',
|
|
139
|
+
tags: ['api'],
|
|
140
|
+
});
|
|
141
|
+
const outcome = await consolidateMemory(store, fakeProvider(jsonReply));
|
|
142
|
+
expect(outcome.clusters[0]?.into).toBe('a-and-b-merged');
|
|
143
|
+
|
|
144
|
+
const remaining = await store.list();
|
|
145
|
+
const names = remaining.map((m) => m.frontmatter.name).sort();
|
|
146
|
+
expect(names).toEqual(['a-and-b-merged']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('handles model output wrapped in ```json fences', async () => {
|
|
150
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
151
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['t'] });
|
|
152
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['t'] });
|
|
153
|
+
|
|
154
|
+
const fenced =
|
|
155
|
+
'```json\n' +
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
name: 'merged',
|
|
158
|
+
type: 'fact',
|
|
159
|
+
description: 'desc',
|
|
160
|
+
body: 'body',
|
|
161
|
+
}) +
|
|
162
|
+
'\n```';
|
|
163
|
+
const outcome = await consolidateMemory(store, fakeProvider(fenced));
|
|
164
|
+
expect(outcome.clusters[0]?.into).toBe('merged');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('skips when there are not enough entries to cluster', async () => {
|
|
168
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
169
|
+
await store.save({ name: 'lonely', type: 'fact', description: 'L', body: 'l' });
|
|
170
|
+
const outcome = await consolidateMemory(store, fakeProvider(''));
|
|
171
|
+
expect(outcome.clusters).toEqual([]);
|
|
172
|
+
expect(outcome.stable).toContain('lonely');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('reports a clear error when a stray } precedes the first { (inverted braces)', async () => {
|
|
176
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
177
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['t'] });
|
|
178
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['t'] });
|
|
179
|
+
|
|
180
|
+
// '}' at index < the first '{' → no well-formed object span. Must yield the
|
|
181
|
+
// intended "no JSON object" message, not an opaque JSON.parse failure.
|
|
182
|
+
await expect(consolidateMemory(store, fakeProvider('done } then {'))).rejects.toThrow(
|
|
183
|
+
/no JSON object/,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('rejects model output that fails schema validation', async () => {
|
|
188
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
189
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['t'] });
|
|
190
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['t'] });
|
|
191
|
+
|
|
192
|
+
const invalid = JSON.stringify({ name: 'Bad Name', type: 'fact', description: 'd', body: 'b' });
|
|
193
|
+
await expect(consolidateMemory(store, fakeProvider(invalid))).rejects.toThrow();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('refuses to clobber an entry merged earlier in the SAME run with a colliding name', async () => {
|
|
197
|
+
// Two independent tag-clusters (api, db). The fake LLM names BOTH merges
|
|
198
|
+
// 'foo'. The first cluster legitimately produces 'foo'. The second cluster
|
|
199
|
+
// must NOT overwrite it — 'foo' is not in the second cluster's members, so
|
|
200
|
+
// writing it would destroy the first merge's body (within-run data loss).
|
|
201
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
202
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['api'] });
|
|
203
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['api'] });
|
|
204
|
+
await store.save({ name: 'c', type: 'fact', description: 'C', body: 'c', tags: ['db'] });
|
|
205
|
+
await store.save({ name: 'd', type: 'fact', description: 'D', body: 'd', tags: ['db'] });
|
|
206
|
+
|
|
207
|
+
const fooReply = JSON.stringify({
|
|
208
|
+
name: 'foo',
|
|
209
|
+
type: 'fact',
|
|
210
|
+
description: 'first merge',
|
|
211
|
+
body: 'FIRST-MERGE-BODY',
|
|
212
|
+
});
|
|
213
|
+
const outcome = await consolidateMemory(store, fakeProvider(fooReply));
|
|
214
|
+
|
|
215
|
+
// Exactly one cluster merged into 'foo'; the other was refused.
|
|
216
|
+
const intos = outcome.clusters.map((c) => c.into);
|
|
217
|
+
expect(intos.filter((x) => x === 'foo')).toHaveLength(1);
|
|
218
|
+
expect(intos.filter((x) => x === null)).toHaveLength(1);
|
|
219
|
+
|
|
220
|
+
// The first merge's body survives — it was not clobbered by the second.
|
|
221
|
+
const remaining = await store.list();
|
|
222
|
+
const foo = remaining.find((e) => e.frontmatter.name === 'foo');
|
|
223
|
+
expect(foo?.body).toBe('FIRST-MERGE-BODY');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('aborts a hung provider stream via timeoutMs and degrades (cluster not merged, no hang)', async () => {
|
|
227
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
228
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['t'] });
|
|
229
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['t'] });
|
|
230
|
+
|
|
231
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
232
|
+
try {
|
|
233
|
+
// A provider that never finishes its stream. Without the timeout this
|
|
234
|
+
// would hang the test forever; with timeoutMs it must resolve, mark the
|
|
235
|
+
// cluster not-merged, and leave both originals intact.
|
|
236
|
+
const outcome = await consolidateMemory(store, hangingProvider(), { timeoutMs: 50 });
|
|
237
|
+
expect(outcome.clusters[0]?.into).toBeNull();
|
|
238
|
+
const remaining = await store.list();
|
|
239
|
+
expect(remaining.map((e) => e.frontmatter.name).sort()).toEqual(['a', 'b']);
|
|
240
|
+
} finally {
|
|
241
|
+
warn.mockRestore();
|
|
242
|
+
}
|
|
243
|
+
}, 5000);
|
|
244
|
+
|
|
245
|
+
it('bounds a provider that IGNORES the abort signal (never yields) — degrades, no hang', async () => {
|
|
246
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
247
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['t'] });
|
|
248
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['t'] });
|
|
249
|
+
|
|
250
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
251
|
+
try {
|
|
252
|
+
const outcome = await consolidateMemory(store, deafProvider(), { timeoutMs: 50 });
|
|
253
|
+
expect(outcome.clusters[0]?.into).toBeNull();
|
|
254
|
+
const remaining = await store.list();
|
|
255
|
+
expect(remaining.map((e) => e.frontmatter.name).sort()).toEqual(['a', 'b']);
|
|
256
|
+
} finally {
|
|
257
|
+
warn.mockRestore();
|
|
258
|
+
}
|
|
259
|
+
}, 5000);
|
|
260
|
+
|
|
261
|
+
it('honors a caller AbortSignal already aborted before the stream starts', async () => {
|
|
262
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
263
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['t'] });
|
|
264
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['t'] });
|
|
265
|
+
|
|
266
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
267
|
+
try {
|
|
268
|
+
const ac = new AbortController();
|
|
269
|
+
ac.abort();
|
|
270
|
+
const outcome = await consolidateMemory(store, hangingProvider(), {
|
|
271
|
+
signal: ac.signal,
|
|
272
|
+
timeoutMs: 0,
|
|
273
|
+
});
|
|
274
|
+
expect(outcome.clusters[0]?.into).toBeNull();
|
|
275
|
+
const remaining = await store.list();
|
|
276
|
+
expect(remaining.map((e) => e.frontmatter.name).sort()).toEqual(['a', 'b']);
|
|
277
|
+
} finally {
|
|
278
|
+
warn.mockRestore();
|
|
279
|
+
}
|
|
280
|
+
}, 5000);
|
|
281
|
+
|
|
282
|
+
it('refuses to overwrite an unrelated memory when the LLM picks a colliding name', async () => {
|
|
283
|
+
// Setup: a + b cluster on tag 'api'; an unrelated entry 'c' exists.
|
|
284
|
+
// If the LLM's consolidated name happens to be 'c', writing it would
|
|
285
|
+
// silently destroy the real 'c'. We expect the merge to be skipped.
|
|
286
|
+
const store = new MemoryStore({ dir: tmp, embedder: null });
|
|
287
|
+
await store.save({ name: 'a', type: 'fact', description: 'A', body: 'a', tags: ['api'] });
|
|
288
|
+
await store.save({ name: 'b', type: 'fact', description: 'B', body: 'b', tags: ['api'] });
|
|
289
|
+
await store.save({ name: 'c', type: 'fact', description: 'C-real', body: 'C-original' });
|
|
290
|
+
|
|
291
|
+
const collidingReply = JSON.stringify({
|
|
292
|
+
name: 'c',
|
|
293
|
+
type: 'fact',
|
|
294
|
+
description: 'merged a+b',
|
|
295
|
+
body: 'should not land',
|
|
296
|
+
});
|
|
297
|
+
const outcome = await consolidateMemory(store, fakeProvider(collidingReply));
|
|
298
|
+
|
|
299
|
+
// The cluster was found but the merge was refused.
|
|
300
|
+
expect(outcome.clusters[0]?.merged).toEqual(['a', 'b']);
|
|
301
|
+
expect(outcome.clusters[0]?.into).toBeNull();
|
|
302
|
+
|
|
303
|
+
// All three originals survive; the real 'c' was not overwritten.
|
|
304
|
+
const remaining = await store.list();
|
|
305
|
+
expect(remaining.map((e) => e.frontmatter.name).sort()).toEqual(['a', 'b', 'c']);
|
|
306
|
+
const realC = remaining.find((e) => e.frontmatter.name === 'c');
|
|
307
|
+
expect(realC?.body).toBe('C-original');
|
|
308
|
+
expect(realC?.frontmatter.description).toBe('C-real');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
function mkEntry(
|
|
313
|
+
name: string,
|
|
314
|
+
overrides: { tags?: string[]; description?: string } = {},
|
|
315
|
+
): import('./store.js').MemoryEntry {
|
|
316
|
+
return {
|
|
317
|
+
frontmatter: {
|
|
318
|
+
name,
|
|
319
|
+
type: 'fact',
|
|
320
|
+
description: overrides.description ?? `entry ${name}`,
|
|
321
|
+
tags: overrides.tags,
|
|
322
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
323
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
324
|
+
},
|
|
325
|
+
body: `body ${name}`,
|
|
326
|
+
path: `/${name}.md`,
|
|
327
|
+
};
|
|
328
|
+
}
|