@illli-studio/dsh-mory 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,395 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join } from 'node:path';
4
+ import { MoryService, MoryError } from '../service.mjs';
5
+ import { apply } from '../index.js';
6
+ import { MorySettingsService } from '../settings.mjs';
7
+
8
+ const memory = (overrides = {}) => ({
9
+ id: 'memory-1', revision: 1, kind: 'note', tags: [], scope: {},
10
+ payload: { title: 'Preference', content: 'Prefers concise answers.' },
11
+ source: 'dsh', status: 'active', isPrivate: false,
12
+ updatedAt: '2026-01-01T00:00:00.000Z', capturedAt: '2026-01-01T00:00:00.000Z',
13
+ ...overrides,
14
+ });
15
+ const json = (value, status = 200) => Response.json(value, { status });
16
+ const hasError = (code, status) => error => {
17
+ assert.ok(error instanceof MoryError);
18
+ assert.equal(error.code, code);
19
+ if (status !== undefined) assert.equal(error.status, status);
20
+ return true;
21
+ };
22
+ function service(fetchImpl = async () => json({}), options = {}) {
23
+ return new MoryService(options.config ?? {}, {
24
+ env: {}, home: '/mory-test-home',
25
+ readFile: async () => JSON.stringify({ port: 8787, token: 'local-secret' }),
26
+ fetch: fetchImpl, ...options.dependencies,
27
+ });
28
+ }
29
+ function host(t, fetchImpl) {
30
+ t.mock.method(MorySettingsService.prototype, 'afterMutation', async () => {});
31
+ t.mock.method(MorySettingsService.prototype, 'deviceOrigin', async () => ({ deviceId: 'test-device', deviceName: 'Test device' }));
32
+ t.mock.method(MoryService.prototype, 'connection', async () => ({
33
+ baseUrl: 'http://127.0.0.1:8787', token: 'host-secret',
34
+ }));
35
+ t.mock.method(globalThis, 'fetch', fetchImpl);
36
+ const disposers = [];
37
+ const registrations = { tool: undefined, route: undefined, removed: [] };
38
+ const ctx = {
39
+ effect(setup) { const dispose = setup(); disposers.push(dispose); return dispose; },
40
+ tools: { register(definition) {
41
+ if (definition.name === 'mory_settings') return () => registrations.removed.push('settingsTool');
42
+ registrations.tool = definition;
43
+ return () => registrations.removed.push('tool');
44
+ } },
45
+ connection: { fetch: { register(definition) {
46
+ registrations.route = definition;
47
+ return async () => { registrations.removed.push('route'); };
48
+ } } },
49
+ };
50
+ apply(ctx);
51
+ registrations.dispose = async () => {
52
+ for (const dispose of disposers.splice(0).reverse()) await dispose?.();
53
+ };
54
+ t.after(() => registrations.dispose());
55
+ return registrations;
56
+ }
57
+ const panelRequest = input => new Request('http://dsh.test/api/mory', {
58
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input),
59
+ });
60
+
61
+ test('configuration discovers MORY_HOME and keeps local credentials on the Host', async () => {
62
+ let readPath;
63
+ const api = service(undefined, { dependencies: {
64
+ env: { MORY_HOME: '/chosen-mory' },
65
+ readFile: async path => { readPath = path; return JSON.stringify({ port: 9888, token: 'private-token' }); },
66
+ } });
67
+ assert.deepEqual(await api.connection(), { baseUrl: 'http://127.0.0.1:9888', token: 'private-token' });
68
+ assert.equal(readPath, join('/chosen-mory', 'config.json'));
69
+ });
70
+
71
+ test('explicit configuration overrides environment URL and selects the named token environment variable', async () => {
72
+ let readPath;
73
+ const api = service(undefined, {
74
+ config: { configPath: '/custom/config.json', apiUrl: 'https://memory.example', tokenEnv: 'CUSTOM_MORY_TOKEN' },
75
+ dependencies: {
76
+ env: { MORY_API_URL: 'https://other.example', MORY_API_TOKEN: 'wrong-token', CUSTOM_MORY_TOKEN: 'chosen-token' },
77
+ readFile: async path => { readPath = path; return '{"token":"local-secret"}'; },
78
+ },
79
+ });
80
+ assert.equal(readPath, undefined);
81
+ assert.deepEqual(await api.connection(), { baseUrl: 'https://memory.example', token: 'chosen-token' });
82
+ assert.equal(readPath, '/custom/config.json');
83
+ });
84
+
85
+ test('a remote URL cannot implicitly receive the local config token', async () => {
86
+ let calls = 0;
87
+ const api = service(async () => { calls++; return json({}); }, { config: { apiUrl: 'https://memory.example' } });
88
+ await assert.rejects(api.execute('status'), hasError('SETUP_REQUIRED', 503));
89
+ assert.equal(calls, 0);
90
+ const explicit = service(undefined, { dependencies: {
91
+ env: { MORY_API_URL: 'https://memory.example', MORY_API_TOKEN: 'remote-secret' },
92
+ } });
93
+ assert.equal((await explicit.connection()).token, 'remote-secret');
94
+ });
95
+
96
+ test('unsafe upstream origins are rejected before any network request', async () => {
97
+ for (const apiUrl of [
98
+ 'http://remote.example', 'ftp://127.0.0.1', 'https://name:secret@memory.example',
99
+ 'https://memory.example/prefix', 'https://memory.example/?token=secret', 'https://memory.example/#fragment',
100
+ ]) {
101
+ await assert.rejects(service(undefined, { config: { apiUrl } }).connection(), hasError('CONFIG_INVALID', 503), apiUrl);
102
+ }
103
+ });
104
+
105
+ test('missing config can use explicit environment credentials while malformed config fails clearly', async () => {
106
+ const absent = Object.assign(new Error('absent'), { code: 'ENOENT' });
107
+ const api = service(undefined, { dependencies: {
108
+ env: { MORY_API_URL: 'https://memory.example', MORY_API_TOKEN: 'remote-secret' },
109
+ readFile: async () => { throw absent; },
110
+ } });
111
+ assert.equal((await api.connection()).token, 'remote-secret');
112
+ const malformed = service(undefined, { dependencies: { readFile: async () => 'not JSON' } });
113
+ await assert.rejects(malformed.connection(), hasError('CONFIG_INVALID', 503));
114
+ });
115
+
116
+ test('status verifies a protected endpoint and does not mistake public health for authentication', async () => {
117
+ const calls = [];
118
+ const api = service(async (url, init) => {
119
+ calls.push({ url: new URL(url), init });
120
+ return json({ error: 'Invalid token.' }, 401);
121
+ });
122
+ await assert.rejects(api.execute('status'), hasError('UNAUTHORIZED', 401));
123
+ assert.equal(calls.length, 1);
124
+ assert.equal(calls[0].url.pathname, '/v1/memories');
125
+ assert.equal(calls[0].init.headers.authorization, 'Bearer local-secret');
126
+ });
127
+
128
+ test('upstream fetch disallows redirects and receives a cancellation signal', async () => {
129
+ const controller = new AbortController();
130
+ const api = service(async (_url, init) => {
131
+ assert.equal(init.redirect, 'error');
132
+ assert.ok(init.signal instanceof AbortSignal);
133
+ assert.equal(init.signal.aborted, false);
134
+ return json({ memories: [], total: 0, offset: 0, nextOffset: null });
135
+ });
136
+ await api.execute('list', {}, controller.signal);
137
+ });
138
+
139
+ test('list preserves pagination and safely encodes project filters without returning embeddings', async () => {
140
+ const api = service(async (url, init) => {
141
+ const target = new URL(url);
142
+ assert.equal(init.method, 'GET');
143
+ assert.equal(target.searchParams.get('limit'), '7');
144
+ assert.equal(target.searchParams.get('offset'), '14');
145
+ assert.equal(target.searchParams.get('projectId'), 'project & private');
146
+ return json({ memories: [memory({ embedding: [0.1, 0.2] })], total: 25, offset: 14, nextOffset: 21 });
147
+ });
148
+ const result = await api.execute('list', { limit: 7, offset: 14, projectId: 'project & private' });
149
+ assert.equal(result.total, 25);
150
+ assert.equal(result.nextOffset, 21);
151
+ assert.equal(result.memories[0].id, 'memory-1');
152
+ assert.equal(result.memories[0].embedding, undefined);
153
+ });
154
+
155
+ test('search and context send the query and project scope in the JSON body', async () => {
156
+ const paths = [];
157
+ const api = service(async (url, init) => {
158
+ paths.push(new URL(url).pathname);
159
+ assert.equal(init.method, 'POST');
160
+ assert.deepEqual(JSON.parse(init.body), { query: '部署偏好', scope: { projectId: 'p-1' }, limit: 3 });
161
+ return json({ results: [{ score: 2, memory: memory() }], memories: [memory()], context: '- Prefers concise answers.' });
162
+ });
163
+ assert.equal((await api.execute('search', { query: '部署偏好', projectId: 'p-1', limit: 3 })).results[0].memory.id, 'memory-1');
164
+ assert.match((await api.execute('context', { query: '部署偏好', projectId: 'p-1', limit: 3 })).context, /concise/);
165
+ assert.deepEqual(paths, ['/v1/memories/search', '/v1/context']);
166
+ });
167
+
168
+ test('create, update and forget use their exact methods, provenance and revision bodies', async () => {
169
+ const calls = [];
170
+ const api = service(async (url, init) => {
171
+ calls.push({ path: new URL(url).pathname, method: init.method, body: JSON.parse(init.body) });
172
+ return json({ ok: true, memory: memory({ revision: calls.length }) });
173
+ });
174
+ await api.execute('create', { text: 'Keep exactly this.', projectId: 'p-1' }, undefined, { type: 'agent', id: 'session-1' });
175
+ await api.execute('update', { memoryId: 'id /?#', text: 'Correction.', expectedRevision: 1 });
176
+ await api.execute('forget', { memoryId: 'memory-1', expectedRevision: 2 });
177
+ assert.equal(calls[0].method, 'POST');
178
+ assert.equal(calls[0].path, '/v1/memories');
179
+ assert.equal(calls[0].body.text, 'Keep exactly this.');
180
+ assert.deepEqual(calls[0].body.actor, { type: 'agent', id: 'session-1' });
181
+ assert.deepEqual(calls[0].body.scope, { projectId: 'p-1' });
182
+ assert.equal(calls[1].method, 'PATCH');
183
+ assert.equal(calls[1].path, '/v1/memories/id%20%2F%3F%23');
184
+ assert.equal(calls[1].body.expectedRevision, 1);
185
+ assert.equal(calls[2].method, 'DELETE');
186
+ assert.deepEqual(calls[2].body, { expectedRevision: 2 });
187
+ });
188
+
189
+ test('invalid revisions and pagination never dispatch a mutation or read', async () => {
190
+ let calls = 0;
191
+ const api = service(async () => { calls++; return json({}); });
192
+ for (const [action, input] of [
193
+ ['update', { memoryId: 'memory-1', text: 'new' }],
194
+ ['forget', { memoryId: 'memory-1' }],
195
+ ['forget', { memoryId: 'memory-1', expectedRevision: 0 }],
196
+ ['update', { memoryId: 'memory-1', text: 'new', expectedRevision: 1.5 }],
197
+ ['list', { limit: 0 }], ['list', { limit: 51 }], ['list', { offset: -1 }],
198
+ ['search', { query: 'q', limit: 1000 }],
199
+ ]) await assert.rejects(api.execute(action, input), hasError('INVALID_INPUT', 400));
200
+ assert.equal(calls, 0);
201
+ });
202
+
203
+ test('a stale revision produces a conflict without retrying or overwriting the winning update', async () => {
204
+ let current = memory();
205
+ let writes = 0;
206
+ const api = service(async (_url, init) => {
207
+ const input = JSON.parse(init.body);
208
+ if (input.expectedRevision !== current.revision) return json({ error: 'Memory changed. Reload before saving.' }, 409);
209
+ writes++;
210
+ current = memory({ revision: current.revision + 1, payload: { title: 'Preference', content: input.text } });
211
+ return json({ ok: true, memory: current });
212
+ });
213
+ await api.execute('update', { memoryId: current.id, expectedRevision: 1, text: 'Winning edit.' });
214
+ await assert.rejects(api.execute('update', { memoryId: current.id, expectedRevision: 1, text: 'Stale edit.' }), hasError('REVISION_CONFLICT', 409));
215
+ assert.equal(writes, 1);
216
+ assert.equal(current.payload.content, 'Winning edit.');
217
+ });
218
+
219
+ test('empty and traversal identifiers are rejected before dispatch', async () => {
220
+ let calls = 0;
221
+ const api = service(async () => { calls++; return json({}); });
222
+ for (const memoryId of ['', ' ', '.', '..']) {
223
+ await assert.rejects(api.execute('get', { memoryId }), hasError('INVALID_INPUT', 400));
224
+ }
225
+ assert.equal(calls, 0);
226
+ });
227
+
228
+ test('context bounds its prompt text and explicitly reports truncation', async () => {
229
+ const api = service(async () => json({ memories: [memory()], context: 'x'.repeat(100_000) }));
230
+ const result = await api.execute('context', { query: 'preference' });
231
+ assert.ok(result.context.length <= 24_000);
232
+ assert.equal(result.truncated, true);
233
+ });
234
+
235
+ test('oversized upstream streams are cancelled before JSON parsing', async () => {
236
+ let cancelled = false;
237
+ const body = new ReadableStream({
238
+ start(controller) { controller.enqueue(new Uint8Array(8 * 1024 * 1024 + 1)); },
239
+ cancel() { cancelled = true; },
240
+ });
241
+ const api = service(async () => new Response(body));
242
+ await assert.rejects(api.request('/v1/memories'), hasError('RESPONSE_TOO_LARGE', 502));
243
+ assert.equal(cancelled, true);
244
+ });
245
+
246
+ test('invalid upstream JSON is reported without exposing the response body', async () => {
247
+ const api = service(async () => new Response('secret invalid response'));
248
+ await assert.rejects(api.request('/v1/memories'), error => {
249
+ hasError('INVALID_RESPONSE', 502)(error);
250
+ assert.doesNotMatch(error.message, /secret/);
251
+ return true;
252
+ });
253
+ });
254
+
255
+ test('caller cancellation interrupts an in-flight upstream fetch', async () => {
256
+ const controller = new AbortController();
257
+ let observed;
258
+ const api = service(async (_url, init) => new Promise((_resolve, reject) => {
259
+ observed = init.signal;
260
+ init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true });
261
+ controller.abort();
262
+ }));
263
+ await assert.rejects(api.request('/v1/memories', 'GET', undefined, controller.signal), hasError('ABORTED', 499));
264
+ assert.equal(observed.aborted, true);
265
+ });
266
+
267
+ test('caller cancellation during response transfer remains a cancellation failure', async () => {
268
+ const controller = new AbortController();
269
+ const api = service(async (_url, init) => {
270
+ const body = new ReadableStream({
271
+ start(stream) {
272
+ init.signal.addEventListener('abort', () => stream.error(init.signal.reason), { once: true });
273
+ },
274
+ pull() { controller.abort(); },
275
+ });
276
+ return new Response(body);
277
+ });
278
+ await assert.rejects(api.request('/v1/memories', 'GET', undefined, controller.signal), hasError('ABORTED', 499));
279
+ });
280
+
281
+ test('the Host route and native tool operate on the same data with caller-specific provenance', async t => {
282
+ let current;
283
+ const actors = [];
284
+ const installed = host(t, async (_url, init) => {
285
+ if (init.method === 'POST') {
286
+ const input = JSON.parse(init.body);
287
+ actors.push(input.actor);
288
+ current = memory({ payload: { title: 'Shared', content: input.text } });
289
+ return json({ memory: current });
290
+ }
291
+ return json({ memories: current ? [current] : [], total: current ? 1 : 0, offset: 0, nextOffset: null });
292
+ });
293
+ assert.equal(installed.route.path, '/api/mory');
294
+ assert.deepEqual(installed.route.methods, ['POST']);
295
+ const created = await installed.route.fetch(panelRequest({ action: 'create', text: 'Created in panel.' }));
296
+ assert.equal(created.status, 200);
297
+ assert.equal(created.headers.get('cache-control'), 'no-store');
298
+ const exec = { signal: new AbortController().signal, agent: { id: 'session-test' } };
299
+ const listed = await installed.tool.execute({ action: 'list' }, exec);
300
+ assert.equal(listed.memories[0].id, (await created.json()).memory.id);
301
+ await installed.tool.execute({ action: 'create', text: 'Created by agent.' }, exec);
302
+ assert.deepEqual(actors, [{ type: 'human', id: 'dsh' }, { type: 'agent', id: 'session-test' }]);
303
+ assert.equal(installed.tool.output.render({}, listed)[0].type, 'text');
304
+ });
305
+
306
+ test('the Host route rejects malformed requests without touching Mory', async t => {
307
+ let calls = 0;
308
+ const installed = host(t, async () => { calls++; return json({}); });
309
+ for (const [body, contentType, expected] of [
310
+ ['{}', 'text/plain', 415], ['{', 'application/json', 400],
311
+ ['[]', 'application/json', 400], ['null', 'application/json', 400],
312
+ ['{"action":"unknown"}', 'application/json', 400],
313
+ ['{"action":"update","memoryId":"m1","text":"new"}', 'application/json', 400],
314
+ ]) {
315
+ const response = await installed.route.fetch(new Request('http://dsh.test/api/mory', {
316
+ method: 'POST', headers: { 'content-type': contentType }, body,
317
+ }));
318
+ assert.equal(response.status, expected);
319
+ assert.equal((await response.json()).error.code, 'INVALID_INPUT');
320
+ }
321
+ assert.equal(calls, 0);
322
+ });
323
+
324
+ test('the streaming Host route rejects oversized bodies before upstream dispatch', async t => {
325
+ let calls = 0;
326
+ const installed = host(t, async () => { calls++; return json({}); });
327
+ assert.equal(installed.route.requestBody, 'streaming');
328
+ const response = await installed.route.fetch(panelRequest({ action: 'create', text: 'x'.repeat(1024 * 1024 + 1) }));
329
+ assert.equal(response.status, 413);
330
+ assert.equal(calls, 0);
331
+ });
332
+
333
+ test('plugin disposal aborts pending operations and unregisters the route and tool', async t => {
334
+ let upstreamSignal;
335
+ let started;
336
+ const ready = new Promise(resolve => { started = resolve; });
337
+ const installed = host(t, async (_url, init) => new Promise((_resolve, reject) => {
338
+ upstreamSignal = init.signal;
339
+ init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true });
340
+ started();
341
+ }));
342
+ const pending = installed.route.fetch(panelRequest({ action: 'status' }));
343
+ await ready;
344
+ await installed.dispose();
345
+ assert.equal(upstreamSignal.aborted, true);
346
+ assert.equal((await pending).status, 499);
347
+ assert.deepEqual(installed.removed.sort(), ['route', 'settingsTool', 'tool']);
348
+ });
349
+
350
+ test('the tool allows concurrent reads but keeps mutations exclusive', async t => {
351
+ const installed = host(t, async () => json({}));
352
+ for (const action of ['status', 'list', 'search', 'context', 'get']) assert.equal(installed.tool.isConcurrencySafe({ action }), true);
353
+ for (const action of ['create', 'update', 'forget', 'unknown']) assert.notEqual(installed.tool.isConcurrencySafe({ action }), true);
354
+ });
355
+
356
+ test('tool get pages complete content without leaking the full payload, while panel get retains the original', async t => {
357
+ const content = '记忆😀line\n'.repeat(4000);
358
+ let calls = 0;
359
+ const installed = host(t, async () => {
360
+ calls++;
361
+ return json({ memory: memory({ payload: { title: 'Paged record', content }, tags: ['comma,inside'] }) });
362
+ });
363
+ const exec = { signal: new AbortController().signal, agent: { id: 'paging-test' } };
364
+ let offset = 0;
365
+ let reconstructed = '';
366
+ let pages = 0;
367
+ do {
368
+ const page = await installed.tool.execute({ action: 'get', memoryId: 'memory-1', textOffset: offset, textLimit: 7777 }, exec);
369
+ assert.equal(page.textOffset, offset);
370
+ assert.equal(page.totalCharacters, content.length);
371
+ assert.ok(page.text.length > 0 && page.text.length <= 7777);
372
+ assert.equal(page.memory.payload, undefined);
373
+ assert.equal(page.memory.revision, 1);
374
+ reconstructed += page.text;
375
+ offset = page.nextTextOffset;
376
+ pages++;
377
+ assert.ok(pages < 20, 'pagination must terminate');
378
+ } while (offset !== null);
379
+ assert.equal(reconstructed, content);
380
+ assert.ok(pages > 1);
381
+ const first = await installed.tool.execute({ action: 'get', memoryId: 'memory-1' }, exec);
382
+ assert.equal(first.text.length, 12000);
383
+ const end = await installed.tool.execute({ action: 'get', memoryId: 'memory-1', textOffset: content.length }, exec);
384
+ assert.equal(end.text, '');
385
+ assert.equal(end.nextTextOffset, null);
386
+ const beforeInvalid = calls;
387
+ for (const paging of [{ textOffset: -1 }, { textOffset: 0.5 }, { textLimit: 0 }, { textLimit: 20001 }]) {
388
+ await assert.rejects(installed.tool.execute({ action: 'get', memoryId: 'memory-1', ...paging }, exec), hasError('INVALID_INPUT', 400));
389
+ }
390
+ assert.equal(calls, beforeInvalid);
391
+ const panel = await installed.route.fetch(panelRequest({ action: 'get', memoryId: 'memory-1' }));
392
+ const record = (await panel.json()).memory;
393
+ assert.equal(record.payload.content, content);
394
+ assert.deepEqual(record.tags, ['comma,inside']);
395
+ });