dsh-memsys 0.2.0 → 0.2.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.
- package/README.md +11 -5
- package/client.js +115 -24
- package/index.mjs +77 -25
- package/package.json +2 -2
- 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 +70 -90
- 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 +368 -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,239 @@ 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
|
+
// 主人要求 120 码点(原来是 80);这里改的是需求,不是放宽判据。
|
|
287
|
+
assert.equal(Array.from(summary.recent.find((row) => row.id === dueMemory.id).text).length, 120);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('P6/UI: routes reject off-loopback and cross-origin callers', () => {
|
|
291
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-sec-'));
|
|
292
|
+
const handler = createSummaryHandler({ main: null, dataDir: dir, getConfig: () => ({ modules: DEFAULT_MODULES }) });
|
|
293
|
+
try {
|
|
294
|
+
const denied = fakeRes();
|
|
295
|
+
handler({ method: 'GET', headers: {}, socket: { remoteAddress: '8.8.8.8' } }, denied);
|
|
296
|
+
assert.equal(denied.statusCode, 403);
|
|
297
|
+
|
|
298
|
+
const cross = fakeRes();
|
|
299
|
+
handler(
|
|
300
|
+
{ method: 'GET', headers: { origin: 'https://evil.example', host: '127.0.0.1:1' }, socket: { remoteAddress: '127.0.0.1' } },
|
|
301
|
+
cross,
|
|
302
|
+
);
|
|
303
|
+
assert.equal(cross.statusCode, 403);
|
|
304
|
+
|
|
305
|
+
const wrongType = fakeRes();
|
|
306
|
+
const badReq = fakeReq({ method: 'POST', headers: { 'content-type': 'text/plain' }, body: 'modules=1' });
|
|
307
|
+
handler(badReq, wrongType);
|
|
308
|
+
badReq.flush();
|
|
309
|
+
assert.equal(wrongType.statusCode, 415);
|
|
310
|
+
|
|
311
|
+
const nothing = fakeRes();
|
|
312
|
+
const emptyReq = fakeReq({ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
313
|
+
handler(emptyReq, nothing);
|
|
314
|
+
emptyReq.flush();
|
|
315
|
+
assert.equal(nothing.statusCode, 400);
|
|
316
|
+
} finally {
|
|
317
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
318
|
+
}
|
|
218
319
|
});
|
|
219
320
|
|
|
220
321
|
test('P6/UI: browser half registers the panel, the sidebar row and the settings section', () => {
|
|
@@ -226,9 +327,8 @@ test('P6/UI: browser half registers the panel, the sidebar row and the settings
|
|
|
226
327
|
assert.equal(loaded?.id, 'dsh-memsys', 'the module id must equal the package name');
|
|
227
328
|
|
|
228
329
|
const React = { createElement: () => null, useState: () => [null, () => {}], useEffect: () => {} };
|
|
229
|
-
const plugin = loaded.factory((name
|
|
330
|
+
const plugin = loaded.factory((name) => {
|
|
230
331
|
assert.equal(name, 'react');
|
|
231
|
-
assert.equal(version, undefined);
|
|
232
332
|
return React;
|
|
233
333
|
});
|
|
234
334
|
|
|
@@ -253,6 +353,18 @@ test('P6/UI: browser half registers the panel, the sidebar row and the settings
|
|
|
253
353
|
assert.equal(byName['settings.section'].spec.id, 'dsh-memsys');
|
|
254
354
|
assert.equal(byName['settings.section'].spec.label(), '记忆系统');
|
|
255
355
|
for (const entry of registered) assert.equal(typeof entry.component, 'function');
|
|
356
|
+
|
|
357
|
+
for (const key of MODULE_KEYS) {
|
|
358
|
+
assert.ok(code.includes(`['${key}',`), `settings page declares a row for ${key}`);
|
|
359
|
+
}
|
|
360
|
+
// Every row must name when the switch takes effect; "立即生效" alone would be a lie
|
|
361
|
+
// for read/dream/projects/feed, which register their tools once at boot.
|
|
362
|
+
const rows = code.split('\n').filter((line) => MODULE_KEYS.some((key) => line.includes(`['${key}',`)));
|
|
363
|
+
assert.equal(rows.length, MODULE_KEYS.length);
|
|
364
|
+
for (const row of rows) {
|
|
365
|
+
assert.match(row, /(改完立即生效|下次启动生效)/, `row must state its timing: ${row.trim()}`);
|
|
366
|
+
}
|
|
367
|
+
assert.equal(code.includes('下次启动生效'), true);
|
|
256
368
|
});
|
|
257
369
|
|
|
258
370
|
test('P6/UI: headless boot without webServer never activates the route scope', async () => {
|
|
@@ -262,10 +374,7 @@ test('P6/UI: headless boot without webServer never activates the route scope', a
|
|
|
262
374
|
const dispose = await apply(ctx, { data_dir: dir, auto_inject: false });
|
|
263
375
|
try {
|
|
264
376
|
assert.equal(typeof dispose, 'function');
|
|
265
|
-
assert.ok(
|
|
266
|
-
scopes.some((services) => services.includes('webServer')),
|
|
267
|
-
'the optional webServer scope is requested',
|
|
268
|
-
);
|
|
377
|
+
assert.ok(scopes.some((services) => services.includes('webServer')));
|
|
269
378
|
assert.equal(fs.existsSync(path.join(dir, 'ui-config.json')), false, 'a headless boot writes nothing');
|
|
270
379
|
} finally {
|
|
271
380
|
dispose();
|
|
@@ -297,8 +406,8 @@ test('P6/UI: with webServer the two exact routes serve loopback and reject cross
|
|
|
297
406
|
const ok = fakeRes();
|
|
298
407
|
routes[0].handler(fakeReq({ headers: { host: '127.0.0.1:50478' } }), ok);
|
|
299
408
|
assert.equal(ok.statusCode, 200);
|
|
300
|
-
assert.equal(ok.json().
|
|
301
|
-
assert.equal(ok.json().
|
|
409
|
+
assert.equal(ok.json().modules.context, false, 'the profile auto_inject:false still wins over the default');
|
|
410
|
+
assert.equal(ok.json().modules_source, 'profile');
|
|
302
411
|
|
|
303
412
|
const cross = fakeRes();
|
|
304
413
|
routes[0].handler(fakeReq({ headers: { origin: 'https://evil.example', host: '127.0.0.1:50478' } }), cross);
|
|
@@ -307,3 +416,152 @@ test('P6/UI: with webServer the two exact routes serve loopback and reject cross
|
|
|
307
416
|
dispose();
|
|
308
417
|
}
|
|
309
418
|
});
|
|
419
|
+
|
|
420
|
+
test('P6/UI: read:false registers four tools and write:false answers module_disabled', async () => {
|
|
421
|
+
const handlers = { memsys_record: async () => ({ status: 'ok' }) };
|
|
422
|
+
const defineTool = (spec) => spec;
|
|
423
|
+
|
|
424
|
+
const collect = async (modules) => {
|
|
425
|
+
const names = [];
|
|
426
|
+
const ctx = { tools: { register: (tool) => { names.push(tool.name); return () => {}; } } };
|
|
427
|
+
await registerHostTools({ ctx, handlers, defineTool, modules });
|
|
428
|
+
return names;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const all = await collect({ ...DEFAULT_MODULES });
|
|
432
|
+
assert.equal(all.length, 10, 'ten tools when every module is on');
|
|
433
|
+
|
|
434
|
+
const readOff = await collect({ ...DEFAULT_MODULES, read: false });
|
|
435
|
+
assert.deepEqual(readOff.sort(), ['memsys_dream_record', 'memsys_feed', 'memsys_organize', 'memsys_record']);
|
|
436
|
+
|
|
437
|
+
const live = { ...DEFAULT_MODULES };
|
|
438
|
+
const defs = [];
|
|
439
|
+
const ctx = { tools: { register: (tool) => { defs.push(tool); return () => {}; } } };
|
|
440
|
+
await registerHostTools({ ctx, handlers, defineTool, modules: live });
|
|
441
|
+
const record = defs.find((tool) => tool.name === 'memsys_record');
|
|
442
|
+
assert.equal((await record.execute({}, {})).status, 'ok');
|
|
443
|
+
live.write = false;
|
|
444
|
+
const blocked = await record.execute({}, {});
|
|
445
|
+
assert.equal(blocked.code, 'module_disabled', 'the call-time gate reads the live object, not a snapshot');
|
|
446
|
+
assert.equal(blocked.module, 'write');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('P6/UI: toggling context re-mounts the systemPrompt section immediately', async () => {
|
|
450
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-section-'));
|
|
451
|
+
const routes = [];
|
|
452
|
+
const sections = [];
|
|
453
|
+
let disposed = 0;
|
|
454
|
+
const ctx = {
|
|
455
|
+
inject(services, callback) {
|
|
456
|
+
if (services.includes('webServer')) {
|
|
457
|
+
callback({ webServer: { register: (route) => { routes.push(route); return () => {}; } }, effect: (fn) => fn() });
|
|
458
|
+
return () => {};
|
|
459
|
+
}
|
|
460
|
+
if (services.includes('systemPrompt')) {
|
|
461
|
+
callback({ systemPrompt: { section: (section) => { sections.push(section); } } });
|
|
462
|
+
return () => { disposed += 1; };
|
|
463
|
+
}
|
|
464
|
+
callback({});
|
|
465
|
+
return () => {};
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const dispose = await apply(ctx, { data_dir: dir }); // no profile config: context defaults to true
|
|
470
|
+
try {
|
|
471
|
+
assert.equal(sections.length, 1, 'context is on by default, so the section mounts at boot');
|
|
472
|
+
|
|
473
|
+
const post = (payload) => {
|
|
474
|
+
const res = fakeRes();
|
|
475
|
+
const req = fakeReq({
|
|
476
|
+
method: 'POST',
|
|
477
|
+
headers: { 'content-type': 'application/json', host: '127.0.0.1:50478' },
|
|
478
|
+
body: JSON.stringify(payload),
|
|
479
|
+
});
|
|
480
|
+
routes[1].handler(req, res);
|
|
481
|
+
req.flush();
|
|
482
|
+
return res;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
assert.equal(post({ modules: { context: false } }).statusCode, 200);
|
|
486
|
+
assert.equal(sections.length, 1, 'turning context off does not mount another section');
|
|
487
|
+
assert.ok(disposed >= 1, 'the mounted section was disposed');
|
|
488
|
+
|
|
489
|
+
assert.equal(post({ modules: { context: true } }).statusCode, 200);
|
|
490
|
+
assert.equal(sections.length, 2, 'turning it back on mounts a fresh section');
|
|
491
|
+
} finally {
|
|
492
|
+
dispose();
|
|
493
|
+
// The injector keeps SQLite handles until the process exits; a locked temp
|
|
494
|
+
// dir on Windows is expected and must not fail the test.
|
|
495
|
+
try {
|
|
496
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
497
|
+
} catch {
|
|
498
|
+
// leave it to the OS temp cleaner
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('P6/UI: the registered section text carries a real memory id, and nothing mounts when context is off', async (t) => {
|
|
504
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
505
|
+
// The injector opens its own SQLite handles on the same dir; a locked temp dir
|
|
506
|
+
// on Windows is expected after the test and must not fail it.
|
|
507
|
+
t.after(() => {
|
|
508
|
+
try {
|
|
509
|
+
ctx.cleanup();
|
|
510
|
+
} catch {
|
|
511
|
+
// leave it to the OS temp cleaner
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const observation = makeObservation(ctx, { text: '主人喜欢米饭,早饭也要一碗。' });
|
|
516
|
+
insertRow(ctx.main, 'observations', observation);
|
|
517
|
+
const candidate = makeCandidate(ctx, observation, { businessKey: 'ui-section-mem' });
|
|
518
|
+
insertRow(ctx.main, 'candidates', candidate);
|
|
519
|
+
const memory = makeMemory(ctx, candidate);
|
|
520
|
+
insertRow(ctx.main, 'memories', memory);
|
|
521
|
+
|
|
522
|
+
const capture = () => {
|
|
523
|
+
const sections = [];
|
|
524
|
+
const routes = [];
|
|
525
|
+
const host = {
|
|
526
|
+
inject(services, callback) {
|
|
527
|
+
if (services.includes('webServer')) {
|
|
528
|
+
callback({ webServer: { register: (route) => { routes.push(route); return () => {}; } }, effect: (fn) => fn() });
|
|
529
|
+
return () => {};
|
|
530
|
+
}
|
|
531
|
+
if (services.includes('systemPrompt')) {
|
|
532
|
+
callback({ systemPrompt: { section: (section) => { sections.push(section); } } });
|
|
533
|
+
return () => {};
|
|
534
|
+
}
|
|
535
|
+
callback({});
|
|
536
|
+
return () => {};
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
return { host, sections, routes };
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const on = capture();
|
|
543
|
+
const disposeOn = await apply(on.host, {
|
|
544
|
+
data_dir: ctx.dir,
|
|
545
|
+
tokenizer: conservativeTokens,
|
|
546
|
+
H: 20000,
|
|
547
|
+
mode: 'work',
|
|
548
|
+
});
|
|
549
|
+
try {
|
|
550
|
+
assert.equal(on.sections.length, 1, 'context:true mounts exactly one section');
|
|
551
|
+
const registered = on.sections[0];
|
|
552
|
+
assert.equal(typeof registered.text, 'function', 'the section reads its text per assemble');
|
|
553
|
+
const text = registered.text();
|
|
554
|
+
assert.ok(text.includes(memory.id), 'the injected text carries the real memory id');
|
|
555
|
+
assert.ok(text.length > 40, 'and it is not the empty/tools-only placeholder');
|
|
556
|
+
} finally {
|
|
557
|
+
disposeOn();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const off = capture();
|
|
561
|
+
const disposeOff = await apply(off.host, { data_dir: ctx.dir, modules: { context: false } });
|
|
562
|
+
try {
|
|
563
|
+
assert.equal(off.sections.length, 0, 'context:false mounts nothing, so nothing is injected');
|
|
564
|
+
} finally {
|
|
565
|
+
disposeOff();
|
|
566
|
+
}
|
|
567
|
+
});
|