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.
@@ -5,12 +5,16 @@ import path from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
7
  import {
8
- buildSummary,
9
- createSummaryHandler,
8
+ DEFAULT_MODULES,
9
+ MODULE_KEYS,
10
+ mergeModules,
10
11
  readUiConfig,
11
- resolveAutoInject,
12
+ resolveModulesDetail,
12
13
  writeUiConfig,
13
- } from '../src/runtime/summary.mjs';
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, routes = [] } = {}) {
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 () => {}; // cordis never runs a scope whose service is absent
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: summary route shape has three counts and five recent memories', (t) => {
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
- const summary = buildSummary(ctx.main, { dataDir: ctx.dir, autoInject: false });
90
- assert.deepEqual(
91
- Object.keys(summary).sort(),
92
- ['auto_inject', 'auto_inject_source', 'candidates', 'data_dir', 'memories', 'pending_due', 'recent'],
93
- );
94
- assert.equal(summary.memories, 0);
95
- assert.equal(summary.candidates, 0);
96
- assert.equal(summary.pending_due, 0);
97
- assert.equal(summary.auto_inject_source, 'default');
98
- assert.ok(Array.isArray(summary.recent));
99
- assert.equal(typeof summary.data_dir, 'string');
100
-
101
- const handler = createSummaryHandler({ main: ctx.main, dataDir: ctx.dir, getAutoInject: () => ({ value: false, source: 'profile' }) });
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({ method: 'GET', headers: {}, socket: { remoteAddress: '127.0.0.1' }, url: '/dsh-memsys/api/summary' }, res);
123
+ handler(fakeReq(), res);
104
124
  assert.equal(res.statusCode, 200);
105
- assert.equal(res.headers['content-type'], 'application/json');
106
- assert.equal(res.json().memories, 0);
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: counts only open candidates, pending-due memories, and truncates recent text', (t) => {
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
- const observation = makeObservation(ctx, { text: '今天在工地把气瓶的阀门换了,顺手把记录补上。' });
127
- insertRow(ctx.main, 'observations', observation);
128
-
129
- const pendingCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-pending' });
130
- const promotedCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-promoted', status: 'promoted' });
131
- insertRow(ctx.main, 'candidates', pendingCandidate);
132
- insertRow(ctx.main, 'candidates', promotedCandidate);
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 longText = '鲸'.repeat(150);
135
- const dueCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-due', text: longText });
136
- insertRow(ctx.main, 'candidates', dueCandidate);
137
- const dueMemory = makeMemory(ctx, dueCandidate, { dueState: 'pending-due' });
138
- insertRow(ctx.main, 'memories', dueMemory);
139
- insertRow(ctx.main, 'memories', makeMemory(ctx, promotedCandidate, { businessKey: 'ui-mem-plain' }));
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
- const summary = buildSummary(ctx.main, { dataDir: ctx.dir, autoInject: false });
142
- assert.equal(summary.memories, 2);
143
- assert.equal(summary.candidates, 2, 'promoted candidates are history, not "候选"');
144
- assert.equal(summary.pending_due, 1);
145
- assert.equal(summary.recent.length, 2);
146
- assert.equal(Array.from(summary.recent.find((row) => row.id === dueMemory.id).text).length, 80);
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.json wins over profile config, and a bad file falls back with one warning', () => {
150
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-prio-'));
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
- assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: true }), { value: true, source: 'profile' });
153
- assert.deepEqual(resolveAutoInject({ dataDir: dir }), { value: false, source: 'default' });
154
-
155
- writeUiConfig(dir, false);
156
- assert.deepEqual(readUiConfig(dir), { auto_inject: false });
157
- assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: true }), { value: false, source: 'ui-config' });
158
- assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'ui-config.json'), 'utf8')).version, 1);
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 badJson = silentLogger();
163
- assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: true, logger: badJson }), { value: true, source: 'profile' });
164
- assert.equal(badJson.warnings.length, 1, 'a corrupt file warns once and never throws');
165
-
166
- fs.writeFileSync(file, JSON.stringify({ version: 99, auto_inject: true }), 'utf8');
167
- const badVersion = silentLogger();
168
- assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: false, logger: badVersion }), { value: false, source: 'profile' });
169
- assert.equal(badVersion.warnings.length, 1);
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: POST config persists the switch and applies it live', (t) => {
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 = { value: false, source: 'profile' };
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
- getAutoInject: () => live,
185
- onAutoInject: (next) => {
186
- writeUiConfig(ctx.dir, next);
187
- live = { value: next, source: 'ui-config' };
188
- applied += 1;
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({ auto_inject: true }),
236
+ body: JSON.stringify({ modules: { context: false } }),
197
237
  });
198
- handler(req, res);
238
+ const posted = fakeRes();
239
+ handler(req, posted);
199
240
  req.flush();
241
+ assert.equal(posted.statusCode, 200);
200
242
 
201
- assert.equal(res.statusCode, 200);
202
- assert.deepEqual(res.json(), { ok: true, auto_inject: true, auto_inject_source: 'ui-config' });
203
- assert.equal(live.value, true, 'the running process sees the new value without a restart');
204
- assert.equal(applied, 1);
205
- assert.deepEqual(readUiConfig(ctx.dir), { auto_inject: true }, 'the switch survives a restart');
206
- assert.equal(fs.existsSync(path.join(ctx.dir, 'ui-config.json')), true);
207
-
208
- const summary = fakeRes();
209
- handler(fakeReq({ headers: { host: '127.0.0.1:50478' } }), summary);
210
- assert.equal(summary.json().auto_inject, true);
211
- assert.equal(summary.json().auto_inject_source, 'ui-config');
212
-
213
- const wrongType = fakeRes();
214
- const badReq = fakeReq({ method: 'POST', headers: { 'content-type': 'text/plain', host: '127.0.0.1:50478' }, body: 'auto_inject=1' });
215
- handler(badReq, wrongType);
216
- badReq.flush();
217
- assert.equal(wrongType.statusCode, 415);
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, version) => {
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().auto_inject, false);
301
- assert.equal(ok.json().auto_inject_source, 'profile', 'the profile patch value is the fallback rank');
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
+ });