dsh-memsys 0.1.0 → 0.2.1
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/README.md +34 -7
- package/client.js +137 -0
- package/index.mjs +152 -11
- package/package.json +9 -2
- package/scripts/build.mjs +2 -1
- package/scripts/import-legacy.mjs +12 -2
- package/scripts/live-check.mjs +1 -1
- package/src/context/compile.mjs +6 -5
- package/src/context/delivery.mjs +1 -1
- package/src/context/snapshots.mjs +10 -2
- package/src/dream/organize.mjs +18 -3
- package/src/import/legacy.mjs +21 -2
- package/src/import/pointers.mjs +62 -20
- package/src/import/rollback.mjs +90 -37
- package/src/read/get.mjs +57 -6
- package/src/runtime/inject-round.mjs +138 -0
- package/src/runtime/inject.mjs +12 -3
- package/src/runtime/modules.mjs +20 -0
- package/src/runtime/summary.mjs +176 -0
- package/src/runtime/tools.mjs +21 -4
- package/src/runtime/ui-config.mjs +176 -0
- package/test/fixtures/p3/c1-baseline.json +90 -0
- package/test/p3-c1-dream.test.mjs +1 -1
- package/test/p4-p4-delivery.test.mjs +7 -1
- package/test/p6-b1-merge.test.mjs +60 -0
- package/test/p6-b2-pointers.test.mjs +77 -0
- package/test/p6-c1c4.test.mjs +69 -0
- package/test/p6-c3-append.test.mjs +85 -0
- package/test/p6-inject-round.test.mjs +76 -0
- package/test/p6-l6-rollback.test.mjs +38 -0
- package/test/p6-modules.test.mjs +77 -0
- package/test/p6-ui-summary.test.mjs +566 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_MODULES,
|
|
9
|
+
MODULE_KEYS,
|
|
10
|
+
mergeModules,
|
|
11
|
+
readUiConfig,
|
|
12
|
+
resolveModulesDetail,
|
|
13
|
+
writeUiConfig,
|
|
14
|
+
} from '../src/runtime/ui-config.mjs';
|
|
15
|
+
import { buildSummary, createSummaryHandler } from '../src/runtime/summary.mjs';
|
|
16
|
+
import { registerHostTools } from '../src/runtime/tools.mjs';
|
|
17
|
+
import { conservativeTokens } from '../src/context/budget.mjs';
|
|
18
|
+
import { insertRow } from '../src/storage/repositories.mjs';
|
|
19
|
+
import { apply } from '../index.mjs';
|
|
20
|
+
import {
|
|
21
|
+
createTestContext,
|
|
22
|
+
makeCandidate,
|
|
23
|
+
makeMemory,
|
|
24
|
+
makeObservation,
|
|
25
|
+
mutableClock,
|
|
26
|
+
} from './helpers/fixtures.mjs';
|
|
27
|
+
|
|
28
|
+
const ROOT = path.resolve(import.meta.dirname, '..');
|
|
29
|
+
|
|
30
|
+
function fakeRes() {
|
|
31
|
+
return {
|
|
32
|
+
statusCode: 0,
|
|
33
|
+
headers: {},
|
|
34
|
+
body: '',
|
|
35
|
+
setHeader(key, value) {
|
|
36
|
+
this.headers[key] = value;
|
|
37
|
+
},
|
|
38
|
+
end(chunk) {
|
|
39
|
+
this.body += chunk ?? '';
|
|
40
|
+
},
|
|
41
|
+
json() {
|
|
42
|
+
return JSON.parse(this.body);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fakeReq({ method = 'GET', headers = {}, body = null, remoteAddress = '127.0.0.1' } = {}) {
|
|
48
|
+
const listeners = new Map();
|
|
49
|
+
const req = {
|
|
50
|
+
method,
|
|
51
|
+
headers,
|
|
52
|
+
url: '/dsh-memsys/api/summary',
|
|
53
|
+
socket: { remoteAddress },
|
|
54
|
+
on(event, handler) {
|
|
55
|
+
listeners.set(event, handler);
|
|
56
|
+
return req;
|
|
57
|
+
},
|
|
58
|
+
flush() {
|
|
59
|
+
if (body !== null) listeners.get('data')?.(body);
|
|
60
|
+
listeners.get('end')?.();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
return req;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function silentLogger() {
|
|
67
|
+
const warnings = [];
|
|
68
|
+
return { warnings, warn: (message) => warnings.push(message) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Records `ctx.inject` scopes; `webServer: null` means "this profile has none". */
|
|
72
|
+
function fakeHost({ webServer = null } = {}) {
|
|
73
|
+
const scopes = [];
|
|
74
|
+
const ctx = {
|
|
75
|
+
inject(services, callback) {
|
|
76
|
+
scopes.push([...services]);
|
|
77
|
+
if (services.includes('webServer')) {
|
|
78
|
+
if (!webServer) return () => {};
|
|
79
|
+
callback({ webServer, effect: (fn) => fn() });
|
|
80
|
+
return () => {};
|
|
81
|
+
}
|
|
82
|
+
callback({});
|
|
83
|
+
return () => {};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
return { ctx, scopes };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('P6/UI: modules default to all true and the summary exposes seven keys', (t) => {
|
|
90
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
91
|
+
t.after(() => ctx.cleanup());
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(Object.keys(DEFAULT_MODULES).sort(), [...MODULE_KEYS].sort());
|
|
94
|
+
assert.ok(Object.values(DEFAULT_MODULES).every((value) => value === true));
|
|
95
|
+
|
|
96
|
+
const detail = resolveModulesDetail({ dataDir: ctx.dir, profileConfig: null, logger: silentLogger() });
|
|
97
|
+
assert.equal(detail.source, 'default');
|
|
98
|
+
assert.deepEqual(detail.modules, DEFAULT_MODULES);
|
|
99
|
+
|
|
100
|
+
const summary = buildSummary(ctx.main, { dataDir: ctx.dir, modules: DEFAULT_MODULES, modulesSource: 'default' });
|
|
101
|
+
assert.deepEqual(Object.keys(summary).sort(), [
|
|
102
|
+
'auto_inject',
|
|
103
|
+
'auto_inject_source',
|
|
104
|
+
'candidates',
|
|
105
|
+
'data_dir',
|
|
106
|
+
'memories',
|
|
107
|
+
'modules',
|
|
108
|
+
'modules_source',
|
|
109
|
+
'pending_due',
|
|
110
|
+
'recent',
|
|
111
|
+
]);
|
|
112
|
+
assert.deepEqual(Object.keys(summary.modules).sort(), [...MODULE_KEYS].sort());
|
|
113
|
+
assert.equal(Object.values(summary.modules).filter(Boolean).length, 7);
|
|
114
|
+
assert.equal(summary.modules_source, 'default');
|
|
115
|
+
assert.equal(summary.auto_inject, true, 'context is on by default');
|
|
116
|
+
|
|
117
|
+
const handler = createSummaryHandler({
|
|
118
|
+
main: ctx.main,
|
|
119
|
+
dataDir: ctx.dir,
|
|
120
|
+
getConfig: () => ({ modules: DEFAULT_MODULES, source: 'default' }),
|
|
121
|
+
});
|
|
122
|
+
const res = fakeRes();
|
|
123
|
+
handler(fakeReq(), res);
|
|
124
|
+
assert.equal(res.statusCode, 200);
|
|
125
|
+
assert.equal(res.json().modules_source, 'default');
|
|
126
|
+
assert.equal(Object.keys(res.json().modules).length, 7);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('P6/UI: POST only the changed module keeps the other six', (t) => {
|
|
130
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
131
|
+
t.after(() => ctx.cleanup());
|
|
132
|
+
|
|
133
|
+
let live = { modules: { ...DEFAULT_MODULES }, source: 'default' };
|
|
134
|
+
const handler = createSummaryHandler({
|
|
135
|
+
main: () => ctx.main,
|
|
136
|
+
dataDir: ctx.dir,
|
|
137
|
+
getConfig: () => live,
|
|
138
|
+
onConfig: (patch) => {
|
|
139
|
+
const written = writeUiConfig(ctx.dir, patch);
|
|
140
|
+
live = { modules: written.modules, source: 'ui-config' };
|
|
141
|
+
return { auto_inject: written.auto_inject, modules: written.modules };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const req = fakeReq({
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'content-type': 'application/json', host: '127.0.0.1:50478' },
|
|
148
|
+
body: JSON.stringify({ modules: { dream: false } }),
|
|
149
|
+
});
|
|
150
|
+
const res = fakeRes();
|
|
151
|
+
handler(req, res);
|
|
152
|
+
req.flush();
|
|
153
|
+
|
|
154
|
+
assert.equal(res.statusCode, 200);
|
|
155
|
+
const body = res.json();
|
|
156
|
+
assert.equal(body.modules.dream, false);
|
|
157
|
+
assert.equal(body.modules_source, 'ui-config');
|
|
158
|
+
assert.equal(body.modules.context, true, 'untouched modules keep their default');
|
|
159
|
+
assert.equal(body.modules.read, true);
|
|
160
|
+
|
|
161
|
+
const stored = JSON.parse(fs.readFileSync(path.join(ctx.dir, 'ui-config.json'), 'utf8'));
|
|
162
|
+
assert.equal(stored.version, 2);
|
|
163
|
+
assert.equal(stored.modules.dream, false);
|
|
164
|
+
assert.equal(stored.modules.write, true);
|
|
165
|
+
assert.equal(stored.auto_inject, true, 'auto_inject mirrors modules.context');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('P6/UI: a v1 ui-config maps auto_inject onto modules.context', () => {
|
|
169
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-v1-'));
|
|
170
|
+
try {
|
|
171
|
+
fs.writeFileSync(path.join(dir, 'ui-config.json'), JSON.stringify({ version: 1, auto_inject: false }), 'utf8');
|
|
172
|
+
const logger = silentLogger();
|
|
173
|
+
const stored = readUiConfig(dir, logger);
|
|
174
|
+
assert.deepEqual(stored.modules, { ...DEFAULT_MODULES, context: false });
|
|
175
|
+
assert.equal(stored.version, 2, 'in memory it is already v2');
|
|
176
|
+
assert.equal(stored.auto_inject, false);
|
|
177
|
+
|
|
178
|
+
const detail = resolveModulesDetail({ dataDir: dir, profileConfig: { auto_inject: true }, logger });
|
|
179
|
+
assert.equal(detail.source, 'ui-config');
|
|
180
|
+
assert.equal(detail.modules.context, false, 'the old switch still turns injection off');
|
|
181
|
+
assert.equal(detail.modules.read, true);
|
|
182
|
+
assert.equal(logger.warnings.length, 0);
|
|
183
|
+
|
|
184
|
+
const legacy = mergeModules({ auto_inject: false, modules: { read: false } }, null);
|
|
185
|
+
assert.equal(legacy.context, false);
|
|
186
|
+
assert.equal(legacy.read, false);
|
|
187
|
+
assert.equal(legacy.write, true);
|
|
188
|
+
} finally {
|
|
189
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('P6/UI: a corrupt or unknown-version file falls back with one warning', () => {
|
|
194
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-bad-'));
|
|
195
|
+
try {
|
|
196
|
+
const file = path.join(dir, 'ui-config.json');
|
|
197
|
+
fs.writeFileSync(file, '{ not json', 'utf8');
|
|
198
|
+
const bad = silentLogger();
|
|
199
|
+
assert.deepEqual(resolveModulesDetail({ dataDir: dir, profileConfig: { auto_inject: false }, logger: bad }), {
|
|
200
|
+
modules: { ...DEFAULT_MODULES, context: false },
|
|
201
|
+
source: 'profile',
|
|
202
|
+
auto_inject: false,
|
|
203
|
+
});
|
|
204
|
+
assert.equal(bad.warnings.length, 1);
|
|
205
|
+
|
|
206
|
+
fs.writeFileSync(file, JSON.stringify({ version: 99, modules: { read: false } }), 'utf8');
|
|
207
|
+
const old = silentLogger();
|
|
208
|
+
const detail = resolveModulesDetail({ dataDir: dir, profileConfig: null, logger: old });
|
|
209
|
+
assert.equal(detail.source, 'default');
|
|
210
|
+
assert.equal(detail.modules.read, true);
|
|
211
|
+
assert.equal(old.warnings.length, 1);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('P6/UI: turning a module off shows false with source ui-config', (t) => {
|
|
218
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
219
|
+
t.after(() => ctx.cleanup());
|
|
220
|
+
|
|
221
|
+
let live = { modules: { ...DEFAULT_MODULES }, source: 'default' };
|
|
222
|
+
const handler = createSummaryHandler({
|
|
223
|
+
main: () => ctx.main,
|
|
224
|
+
dataDir: ctx.dir,
|
|
225
|
+
getConfig: () => live,
|
|
226
|
+
onConfig: (patch) => {
|
|
227
|
+
const written = writeUiConfig(ctx.dir, patch);
|
|
228
|
+
live = { modules: written.modules, source: 'ui-config' };
|
|
229
|
+
return { auto_inject: written.auto_inject, modules: written.modules };
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const req = fakeReq({
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: { 'content-type': 'application/json', host: '127.0.0.1:50478' },
|
|
236
|
+
body: JSON.stringify({ modules: { context: false } }),
|
|
237
|
+
});
|
|
238
|
+
const posted = fakeRes();
|
|
239
|
+
handler(req, posted);
|
|
240
|
+
req.flush();
|
|
241
|
+
assert.equal(posted.statusCode, 200);
|
|
242
|
+
|
|
243
|
+
const res = fakeRes();
|
|
244
|
+
handler(fakeReq(), res);
|
|
245
|
+
const body = res.json();
|
|
246
|
+
assert.equal(body.modules.context, false);
|
|
247
|
+
assert.equal(body.auto_inject, false);
|
|
248
|
+
assert.equal(body.modules_source, 'ui-config');
|
|
249
|
+
assert.equal(body.auto_inject_source, 'ui-config');
|
|
250
|
+
|
|
251
|
+
const legacy = fakeReq({
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'content-type': 'application/json', host: '127.0.0.1:50478' },
|
|
254
|
+
body: JSON.stringify({ auto_inject: true }),
|
|
255
|
+
});
|
|
256
|
+
const legacyRes = fakeRes();
|
|
257
|
+
handler(legacy, legacyRes);
|
|
258
|
+
legacy.flush();
|
|
259
|
+
assert.equal(legacyRes.json().auto_inject, true);
|
|
260
|
+
assert.equal(legacyRes.json().modules.context, true, 'an old auto_inject call still maps to context');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('P6/UI: counts only open candidates, pending-due memories, and truncates recent text', (t) => {
|
|
264
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
265
|
+
t.after(() => ctx.cleanup());
|
|
266
|
+
|
|
267
|
+
const observation = makeObservation(ctx, { text: '今天在工地把气瓶的阀门换了,顺手把记录补上。' });
|
|
268
|
+
insertRow(ctx.main, 'observations', observation);
|
|
269
|
+
|
|
270
|
+
insertRow(ctx.main, 'candidates', makeCandidate(ctx, observation, { businessKey: 'ui-cand-pending' }));
|
|
271
|
+
const promotedCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-promoted', status: 'promoted' });
|
|
272
|
+
insertRow(ctx.main, 'candidates', promotedCandidate);
|
|
273
|
+
|
|
274
|
+
const longText = '鲸'.repeat(150);
|
|
275
|
+
const dueCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-due', text: longText });
|
|
276
|
+
insertRow(ctx.main, 'candidates', dueCandidate);
|
|
277
|
+
const dueMemory = makeMemory(ctx, dueCandidate, { dueState: 'pending-due' });
|
|
278
|
+
insertRow(ctx.main, 'memories', dueMemory);
|
|
279
|
+
insertRow(ctx.main, 'memories', makeMemory(ctx, promotedCandidate, { businessKey: 'ui-mem-plain' }));
|
|
280
|
+
|
|
281
|
+
const summary = buildSummary(ctx.main, { dataDir: ctx.dir });
|
|
282
|
+
assert.equal(summary.memories, 2);
|
|
283
|
+
assert.equal(summary.candidates, 2, 'promoted candidates are history, not "候选"');
|
|
284
|
+
assert.equal(summary.pending_due, 1);
|
|
285
|
+
assert.equal(summary.recent.length, 2);
|
|
286
|
+
assert.equal(Array.from(summary.recent.find((row) => row.id === dueMemory.id).text).length, 80);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('P6/UI: routes reject off-loopback and cross-origin callers', () => {
|
|
290
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-sec-'));
|
|
291
|
+
const handler = createSummaryHandler({ main: null, dataDir: dir, getConfig: () => ({ modules: DEFAULT_MODULES }) });
|
|
292
|
+
try {
|
|
293
|
+
const denied = fakeRes();
|
|
294
|
+
handler({ method: 'GET', headers: {}, socket: { remoteAddress: '8.8.8.8' } }, denied);
|
|
295
|
+
assert.equal(denied.statusCode, 403);
|
|
296
|
+
|
|
297
|
+
const cross = fakeRes();
|
|
298
|
+
handler(
|
|
299
|
+
{ method: 'GET', headers: { origin: 'https://evil.example', host: '127.0.0.1:1' }, socket: { remoteAddress: '127.0.0.1' } },
|
|
300
|
+
cross,
|
|
301
|
+
);
|
|
302
|
+
assert.equal(cross.statusCode, 403);
|
|
303
|
+
|
|
304
|
+
const wrongType = fakeRes();
|
|
305
|
+
const badReq = fakeReq({ method: 'POST', headers: { 'content-type': 'text/plain' }, body: 'modules=1' });
|
|
306
|
+
handler(badReq, wrongType);
|
|
307
|
+
badReq.flush();
|
|
308
|
+
assert.equal(wrongType.statusCode, 415);
|
|
309
|
+
|
|
310
|
+
const nothing = fakeRes();
|
|
311
|
+
const emptyReq = fakeReq({ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
312
|
+
handler(emptyReq, nothing);
|
|
313
|
+
emptyReq.flush();
|
|
314
|
+
assert.equal(nothing.statusCode, 400);
|
|
315
|
+
} finally {
|
|
316
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('P6/UI: browser half registers the panel, the sidebar row and the settings section', () => {
|
|
321
|
+
const code = fs.readFileSync(path.join(ROOT, 'client.js'), 'utf8');
|
|
322
|
+
|
|
323
|
+
let loaded = null;
|
|
324
|
+
const window = { __ModuleLoader__: { load: (mod) => { loaded = mod; } } };
|
|
325
|
+
new Function('window', code)(window);
|
|
326
|
+
assert.equal(loaded?.id, 'dsh-memsys', 'the module id must equal the package name');
|
|
327
|
+
|
|
328
|
+
const React = { createElement: () => null, useState: () => [null, () => {}], useEffect: () => {} };
|
|
329
|
+
const plugin = loaded.factory((name) => {
|
|
330
|
+
assert.equal(name, 'react');
|
|
331
|
+
return React;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const registered = [];
|
|
335
|
+
const slots = {
|
|
336
|
+
inject(name, callback) {
|
|
337
|
+
assert.ok(['main', 'sidebar.panellist', 'settings.section'].includes(name));
|
|
338
|
+
callback();
|
|
339
|
+
},
|
|
340
|
+
register(spec, component) {
|
|
341
|
+
registered.push({ spec, component });
|
|
342
|
+
return () => {};
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
assert.deepEqual(plugin.inject, ['slots']);
|
|
346
|
+
plugin.apply({ slots });
|
|
347
|
+
|
|
348
|
+
const byName = Object.fromEntries(registered.map((entry) => [entry.spec.name, entry]));
|
|
349
|
+
assert.equal(byName.main.spec.key, 'dsh-memsys');
|
|
350
|
+
assert.equal(byName['sidebar.panellist'].spec.id, 'dsh-memsys');
|
|
351
|
+
assert.equal(byName['sidebar.panellist'].spec.label(), '记忆');
|
|
352
|
+
assert.equal(byName['settings.section'].spec.id, 'dsh-memsys');
|
|
353
|
+
assert.equal(byName['settings.section'].spec.label(), '记忆系统');
|
|
354
|
+
for (const entry of registered) assert.equal(typeof entry.component, 'function');
|
|
355
|
+
|
|
356
|
+
for (const key of MODULE_KEYS) {
|
|
357
|
+
assert.ok(code.includes(`['${key}',`), `settings page declares a row for ${key}`);
|
|
358
|
+
}
|
|
359
|
+
// Every row must name when the switch takes effect; "立即生效" alone would be a lie
|
|
360
|
+
// for read/dream/projects/feed, which register their tools once at boot.
|
|
361
|
+
const rows = code.split('\n').filter((line) => MODULE_KEYS.some((key) => line.includes(`['${key}',`)));
|
|
362
|
+
assert.equal(rows.length, MODULE_KEYS.length);
|
|
363
|
+
for (const row of rows) {
|
|
364
|
+
assert.match(row, /(改完立即生效|下次启动生效)/, `row must state its timing: ${row.trim()}`);
|
|
365
|
+
}
|
|
366
|
+
assert.equal(code.includes('下次启动生效'), true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('P6/UI: headless boot without webServer never activates the route scope', async () => {
|
|
370
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-headless-'));
|
|
371
|
+
const { ctx, scopes } = fakeHost({ webServer: null });
|
|
372
|
+
|
|
373
|
+
const dispose = await apply(ctx, { data_dir: dir, auto_inject: false });
|
|
374
|
+
try {
|
|
375
|
+
assert.equal(typeof dispose, 'function');
|
|
376
|
+
assert.ok(scopes.some((services) => services.includes('webServer')));
|
|
377
|
+
assert.equal(fs.existsSync(path.join(dir, 'ui-config.json')), false, 'a headless boot writes nothing');
|
|
378
|
+
} finally {
|
|
379
|
+
dispose();
|
|
380
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('P6/UI: with webServer the two exact routes serve loopback and reject cross-origin', async () => {
|
|
385
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-web-'));
|
|
386
|
+
const routes = [];
|
|
387
|
+
const webServer = {
|
|
388
|
+
register(route) {
|
|
389
|
+
routes.push(route);
|
|
390
|
+
return () => {};
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
const { ctx } = fakeHost({ webServer });
|
|
394
|
+
|
|
395
|
+
const dispose = await apply(ctx, { data_dir: dir, auto_inject: false });
|
|
396
|
+
try {
|
|
397
|
+
assert.deepEqual(
|
|
398
|
+
routes.map((route) => [route.kind, route.path]),
|
|
399
|
+
[
|
|
400
|
+
['exact', '/dsh-memsys/api/summary'],
|
|
401
|
+
['exact', '/dsh-memsys/api/config'],
|
|
402
|
+
],
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const ok = fakeRes();
|
|
406
|
+
routes[0].handler(fakeReq({ headers: { host: '127.0.0.1:50478' } }), ok);
|
|
407
|
+
assert.equal(ok.statusCode, 200);
|
|
408
|
+
assert.equal(ok.json().modules.context, false, 'the profile auto_inject:false still wins over the default');
|
|
409
|
+
assert.equal(ok.json().modules_source, 'profile');
|
|
410
|
+
|
|
411
|
+
const cross = fakeRes();
|
|
412
|
+
routes[0].handler(fakeReq({ headers: { origin: 'https://evil.example', host: '127.0.0.1:50478' } }), cross);
|
|
413
|
+
assert.equal(cross.statusCode, 403);
|
|
414
|
+
} finally {
|
|
415
|
+
dispose();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('P6/UI: read:false registers four tools and write:false answers module_disabled', async () => {
|
|
420
|
+
const handlers = { memsys_record: async () => ({ status: 'ok' }) };
|
|
421
|
+
const defineTool = (spec) => spec;
|
|
422
|
+
|
|
423
|
+
const collect = async (modules) => {
|
|
424
|
+
const names = [];
|
|
425
|
+
const ctx = { tools: { register: (tool) => { names.push(tool.name); return () => {}; } } };
|
|
426
|
+
await registerHostTools({ ctx, handlers, defineTool, modules });
|
|
427
|
+
return names;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const all = await collect({ ...DEFAULT_MODULES });
|
|
431
|
+
assert.equal(all.length, 10, 'ten tools when every module is on');
|
|
432
|
+
|
|
433
|
+
const readOff = await collect({ ...DEFAULT_MODULES, read: false });
|
|
434
|
+
assert.deepEqual(readOff.sort(), ['memsys_dream_record', 'memsys_feed', 'memsys_organize', 'memsys_record']);
|
|
435
|
+
|
|
436
|
+
const live = { ...DEFAULT_MODULES };
|
|
437
|
+
const defs = [];
|
|
438
|
+
const ctx = { tools: { register: (tool) => { defs.push(tool); return () => {}; } } };
|
|
439
|
+
await registerHostTools({ ctx, handlers, defineTool, modules: live });
|
|
440
|
+
const record = defs.find((tool) => tool.name === 'memsys_record');
|
|
441
|
+
assert.equal((await record.execute({}, {})).status, 'ok');
|
|
442
|
+
live.write = false;
|
|
443
|
+
const blocked = await record.execute({}, {});
|
|
444
|
+
assert.equal(blocked.code, 'module_disabled', 'the call-time gate reads the live object, not a snapshot');
|
|
445
|
+
assert.equal(blocked.module, 'write');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('P6/UI: toggling context re-mounts the systemPrompt section immediately', async () => {
|
|
449
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-section-'));
|
|
450
|
+
const routes = [];
|
|
451
|
+
const sections = [];
|
|
452
|
+
let disposed = 0;
|
|
453
|
+
const ctx = {
|
|
454
|
+
inject(services, callback) {
|
|
455
|
+
if (services.includes('webServer')) {
|
|
456
|
+
callback({ webServer: { register: (route) => { routes.push(route); return () => {}; } }, effect: (fn) => fn() });
|
|
457
|
+
return () => {};
|
|
458
|
+
}
|
|
459
|
+
if (services.includes('systemPrompt')) {
|
|
460
|
+
callback({ systemPrompt: { section: (section) => { sections.push(section); } } });
|
|
461
|
+
return () => { disposed += 1; };
|
|
462
|
+
}
|
|
463
|
+
callback({});
|
|
464
|
+
return () => {};
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const dispose = await apply(ctx, { data_dir: dir }); // no profile config: context defaults to true
|
|
469
|
+
try {
|
|
470
|
+
assert.equal(sections.length, 1, 'context is on by default, so the section mounts at boot');
|
|
471
|
+
|
|
472
|
+
const post = (payload) => {
|
|
473
|
+
const res = fakeRes();
|
|
474
|
+
const req = fakeReq({
|
|
475
|
+
method: 'POST',
|
|
476
|
+
headers: { 'content-type': 'application/json', host: '127.0.0.1:50478' },
|
|
477
|
+
body: JSON.stringify(payload),
|
|
478
|
+
});
|
|
479
|
+
routes[1].handler(req, res);
|
|
480
|
+
req.flush();
|
|
481
|
+
return res;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
assert.equal(post({ modules: { context: false } }).statusCode, 200);
|
|
485
|
+
assert.equal(sections.length, 1, 'turning context off does not mount another section');
|
|
486
|
+
assert.ok(disposed >= 1, 'the mounted section was disposed');
|
|
487
|
+
|
|
488
|
+
assert.equal(post({ modules: { context: true } }).statusCode, 200);
|
|
489
|
+
assert.equal(sections.length, 2, 'turning it back on mounts a fresh section');
|
|
490
|
+
} finally {
|
|
491
|
+
dispose();
|
|
492
|
+
// The injector keeps SQLite handles until the process exits; a locked temp
|
|
493
|
+
// dir on Windows is expected and must not fail the test.
|
|
494
|
+
try {
|
|
495
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
496
|
+
} catch {
|
|
497
|
+
// leave it to the OS temp cleaner
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('P6/UI: the registered section text carries a real memory id, and nothing mounts when context is off', async (t) => {
|
|
503
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
504
|
+
// The injector opens its own SQLite handles on the same dir; a locked temp dir
|
|
505
|
+
// on Windows is expected after the test and must not fail it.
|
|
506
|
+
t.after(() => {
|
|
507
|
+
try {
|
|
508
|
+
ctx.cleanup();
|
|
509
|
+
} catch {
|
|
510
|
+
// leave it to the OS temp cleaner
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const observation = makeObservation(ctx, { text: '主人喜欢米饭,早饭也要一碗。' });
|
|
515
|
+
insertRow(ctx.main, 'observations', observation);
|
|
516
|
+
const candidate = makeCandidate(ctx, observation, { businessKey: 'ui-section-mem' });
|
|
517
|
+
insertRow(ctx.main, 'candidates', candidate);
|
|
518
|
+
const memory = makeMemory(ctx, candidate);
|
|
519
|
+
insertRow(ctx.main, 'memories', memory);
|
|
520
|
+
|
|
521
|
+
const capture = () => {
|
|
522
|
+
const sections = [];
|
|
523
|
+
const routes = [];
|
|
524
|
+
const host = {
|
|
525
|
+
inject(services, callback) {
|
|
526
|
+
if (services.includes('webServer')) {
|
|
527
|
+
callback({ webServer: { register: (route) => { routes.push(route); return () => {}; } }, effect: (fn) => fn() });
|
|
528
|
+
return () => {};
|
|
529
|
+
}
|
|
530
|
+
if (services.includes('systemPrompt')) {
|
|
531
|
+
callback({ systemPrompt: { section: (section) => { sections.push(section); } } });
|
|
532
|
+
return () => {};
|
|
533
|
+
}
|
|
534
|
+
callback({});
|
|
535
|
+
return () => {};
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
return { host, sections, routes };
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const on = capture();
|
|
542
|
+
const disposeOn = await apply(on.host, {
|
|
543
|
+
data_dir: ctx.dir,
|
|
544
|
+
tokenizer: conservativeTokens,
|
|
545
|
+
H: 20000,
|
|
546
|
+
mode: 'work',
|
|
547
|
+
});
|
|
548
|
+
try {
|
|
549
|
+
assert.equal(on.sections.length, 1, 'context:true mounts exactly one section');
|
|
550
|
+
const registered = on.sections[0];
|
|
551
|
+
assert.equal(typeof registered.text, 'function', 'the section reads its text per assemble');
|
|
552
|
+
const text = registered.text();
|
|
553
|
+
assert.ok(text.includes(memory.id), 'the injected text carries the real memory id');
|
|
554
|
+
assert.ok(text.length > 40, 'and it is not the empty/tools-only placeholder');
|
|
555
|
+
} finally {
|
|
556
|
+
disposeOn();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const off = capture();
|
|
560
|
+
const disposeOff = await apply(off.host, { data_dir: ctx.dir, modules: { context: false } });
|
|
561
|
+
try {
|
|
562
|
+
assert.equal(off.sections.length, 0, 'context:false mounts nothing, so nothing is injected');
|
|
563
|
+
} finally {
|
|
564
|
+
disposeOff();
|
|
565
|
+
}
|
|
566
|
+
});
|