dsh-memsys 0.2.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 +11 -5
- package/client.js +43 -16
- package/index.mjs +77 -25
- package/package.json +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/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 +68 -89
- package/src/runtime/tools.mjs +21 -4
- package/src/runtime/ui-config.mjs +176 -0
- package/test/p4-p4-delivery.test.mjs +7 -1
- 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-modules.test.mjs +77 -0
- package/test/p6-ui-summary.test.mjs +367 -110
|
@@ -5,12 +5,16 @@ import path from 'node:path';
|
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
DEFAULT_MODULES,
|
|
9
|
+
MODULE_KEYS,
|
|
10
|
+
mergeModules,
|
|
10
11
|
readUiConfig,
|
|
11
|
-
|
|
12
|
+
resolveModulesDetail,
|
|
12
13
|
writeUiConfig,
|
|
13
|
-
} from '../src/runtime/
|
|
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';
|
|
14
18
|
import { insertRow } from '../src/storage/repositories.mjs';
|
|
15
19
|
import { apply } from '../index.mjs';
|
|
16
20
|
import {
|
|
@@ -65,13 +69,13 @@ function silentLogger() {
|
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
/** Records `ctx.inject` scopes; `webServer: null` means "this profile has none". */
|
|
68
|
-
function fakeHost({ webServer = null
|
|
72
|
+
function fakeHost({ webServer = null } = {}) {
|
|
69
73
|
const scopes = [];
|
|
70
74
|
const ctx = {
|
|
71
75
|
inject(services, callback) {
|
|
72
76
|
scopes.push([...services]);
|
|
73
77
|
if (services.includes('webServer')) {
|
|
74
|
-
if (!webServer) return () => {};
|
|
78
|
+
if (!webServer) return () => {};
|
|
75
79
|
callback({ webServer, effect: (fn) => fn() });
|
|
76
80
|
return () => {};
|
|
77
81
|
}
|
|
@@ -79,142 +83,238 @@ function fakeHost({ webServer = null, routes = [] } = {}) {
|
|
|
79
83
|
return () => {};
|
|
80
84
|
},
|
|
81
85
|
};
|
|
82
|
-
void routes;
|
|
83
86
|
return { ctx, scopes };
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
test('P6/UI:
|
|
89
|
+
test('P6/UI: modules default to all true and the summary exposes seven keys', (t) => {
|
|
87
90
|
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
88
91
|
t.after(() => ctx.cleanup());
|
|
89
|
-
|
|
90
|
-
assert.deepEqual(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
);
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
assert.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
});
|
|
102
122
|
const res = fakeRes();
|
|
103
|
-
handler(
|
|
123
|
+
handler(fakeReq(), res);
|
|
104
124
|
assert.equal(res.statusCode, 200);
|
|
105
|
-
assert.equal(res.
|
|
106
|
-
assert.equal(res.json().
|
|
107
|
-
assert.equal(res.json().auto_inject_source, 'profile');
|
|
108
|
-
|
|
109
|
-
const denied = fakeRes();
|
|
110
|
-
handler({ method: 'GET', headers: {}, socket: { remoteAddress: '8.8.8.8' }, url: '/dsh-memsys/api/summary' }, denied);
|
|
111
|
-
assert.equal(denied.statusCode, 403);
|
|
112
|
-
assert.match(denied.body, /forbidden/);
|
|
113
|
-
|
|
114
|
-
const cross = fakeRes();
|
|
115
|
-
handler(
|
|
116
|
-
{ method: 'GET', headers: { origin: 'https://evil.example', host: '127.0.0.1:1' }, socket: { remoteAddress: '127.0.0.1' }, url: '/dsh-memsys/api/summary' },
|
|
117
|
-
cross,
|
|
118
|
-
);
|
|
119
|
-
assert.equal(cross.statusCode, 403);
|
|
125
|
+
assert.equal(res.json().modules_source, 'default');
|
|
126
|
+
assert.equal(Object.keys(res.json().modules).length, 7);
|
|
120
127
|
});
|
|
121
128
|
|
|
122
|
-
test('P6/UI:
|
|
129
|
+
test('P6/UI: POST only the changed module keeps the other six', (t) => {
|
|
123
130
|
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
124
131
|
t.after(() => ctx.cleanup());
|
|
125
132
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
});
|
|
133
144
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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();
|
|
140
153
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
assert.equal(
|
|
144
|
-
assert.equal(
|
|
145
|
-
assert.equal(
|
|
146
|
-
assert.equal(
|
|
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');
|
|
147
166
|
});
|
|
148
167
|
|
|
149
|
-
test('P6/UI: ui-config
|
|
150
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-
|
|
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-'));
|
|
151
170
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
assert.
|
|
157
|
-
assert.
|
|
158
|
-
|
|
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
|
+
});
|
|
159
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 {
|
|
160
196
|
const file = path.join(dir, 'ui-config.json');
|
|
161
197
|
fs.writeFileSync(file, '{ not json', 'utf8');
|
|
162
|
-
const
|
|
163
|
-
assert.deepEqual(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
assert.
|
|
169
|
-
|
|
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);
|
|
170
212
|
} finally {
|
|
171
213
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
172
214
|
}
|
|
173
215
|
});
|
|
174
216
|
|
|
175
|
-
test('P6/UI:
|
|
217
|
+
test('P6/UI: turning a module off shows false with source ui-config', (t) => {
|
|
176
218
|
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
177
219
|
t.after(() => ctx.cleanup());
|
|
178
220
|
|
|
179
|
-
let live = {
|
|
180
|
-
let applied = 0;
|
|
221
|
+
let live = { modules: { ...DEFAULT_MODULES }, source: 'default' };
|
|
181
222
|
const handler = createSummaryHandler({
|
|
182
223
|
main: () => ctx.main,
|
|
183
224
|
dataDir: ctx.dir,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
writeUiConfig(ctx.dir,
|
|
187
|
-
live = {
|
|
188
|
-
|
|
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 };
|
|
189
230
|
},
|
|
190
231
|
});
|
|
191
232
|
|
|
192
|
-
const res = fakeRes();
|
|
193
233
|
const req = fakeReq({
|
|
194
234
|
method: 'POST',
|
|
195
235
|
headers: { 'content-type': 'application/json', host: '127.0.0.1:50478' },
|
|
196
|
-
body: JSON.stringify({
|
|
236
|
+
body: JSON.stringify({ modules: { context: false } }),
|
|
197
237
|
});
|
|
198
|
-
|
|
238
|
+
const posted = fakeRes();
|
|
239
|
+
handler(req, posted);
|
|
199
240
|
req.flush();
|
|
241
|
+
assert.equal(posted.statusCode, 200);
|
|
200
242
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
assert.equal(
|
|
205
|
-
assert.
|
|
206
|
-
assert.equal(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
handler(
|
|
216
|
-
|
|
217
|
-
assert.equal(
|
|
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
|
+
}
|
|
218
318
|
});
|
|
219
319
|
|
|
220
320
|
test('P6/UI: browser half registers the panel, the sidebar row and the settings section', () => {
|
|
@@ -226,9 +326,8 @@ test('P6/UI: browser half registers the panel, the sidebar row and the settings
|
|
|
226
326
|
assert.equal(loaded?.id, 'dsh-memsys', 'the module id must equal the package name');
|
|
227
327
|
|
|
228
328
|
const React = { createElement: () => null, useState: () => [null, () => {}], useEffect: () => {} };
|
|
229
|
-
const plugin = loaded.factory((name
|
|
329
|
+
const plugin = loaded.factory((name) => {
|
|
230
330
|
assert.equal(name, 'react');
|
|
231
|
-
assert.equal(version, undefined);
|
|
232
331
|
return React;
|
|
233
332
|
});
|
|
234
333
|
|
|
@@ -253,6 +352,18 @@ test('P6/UI: browser half registers the panel, the sidebar row and the settings
|
|
|
253
352
|
assert.equal(byName['settings.section'].spec.id, 'dsh-memsys');
|
|
254
353
|
assert.equal(byName['settings.section'].spec.label(), '记忆系统');
|
|
255
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);
|
|
256
367
|
});
|
|
257
368
|
|
|
258
369
|
test('P6/UI: headless boot without webServer never activates the route scope', async () => {
|
|
@@ -262,10 +373,7 @@ test('P6/UI: headless boot without webServer never activates the route scope', a
|
|
|
262
373
|
const dispose = await apply(ctx, { data_dir: dir, auto_inject: false });
|
|
263
374
|
try {
|
|
264
375
|
assert.equal(typeof dispose, 'function');
|
|
265
|
-
assert.ok(
|
|
266
|
-
scopes.some((services) => services.includes('webServer')),
|
|
267
|
-
'the optional webServer scope is requested',
|
|
268
|
-
);
|
|
376
|
+
assert.ok(scopes.some((services) => services.includes('webServer')));
|
|
269
377
|
assert.equal(fs.existsSync(path.join(dir, 'ui-config.json')), false, 'a headless boot writes nothing');
|
|
270
378
|
} finally {
|
|
271
379
|
dispose();
|
|
@@ -297,8 +405,8 @@ test('P6/UI: with webServer the two exact routes serve loopback and reject cross
|
|
|
297
405
|
const ok = fakeRes();
|
|
298
406
|
routes[0].handler(fakeReq({ headers: { host: '127.0.0.1:50478' } }), ok);
|
|
299
407
|
assert.equal(ok.statusCode, 200);
|
|
300
|
-
assert.equal(ok.json().
|
|
301
|
-
assert.equal(ok.json().
|
|
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');
|
|
302
410
|
|
|
303
411
|
const cross = fakeRes();
|
|
304
412
|
routes[0].handler(fakeReq({ headers: { origin: 'https://evil.example', host: '127.0.0.1:50478' } }), cross);
|
|
@@ -307,3 +415,152 @@ test('P6/UI: with webServer the two exact routes serve loopback and reject cross
|
|
|
307
415
|
dispose();
|
|
308
416
|
}
|
|
309
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
|
+
});
|