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.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Core gate facade. The single merge/persistence source is ui-config.mjs;
3
+ * this file must never re-implement it.
4
+ */
5
+ export {
6
+ DEFAULT_MODULES,
7
+ MODULE_KEYS,
8
+ moduleEnabled,
9
+ readUiConfig,
10
+ resolveModules,
11
+ resolveModulesDetail,
12
+ writeUiConfig,
13
+ } from './ui-config.mjs';
14
+
15
+ import { resolveModulesDetail } from './ui-config.mjs';
16
+
17
+ /** Resolve the effective gate set for one plugin instance. */
18
+ export function modulesFor({ dataDir = null, config = null, logger = console } = {}) {
19
+ return resolveModulesDetail({ dataDir, profileConfig: config, logger }).modules;
20
+ }
@@ -1,105 +1,47 @@
1
1
  /**
2
2
  * Host side of the optional Web UI (sidebar panel + settings section).
3
3
  *
4
- * Everything here is read-only over SQLite plus one small JSON file holding the
5
- * `auto_inject` override written by the settings switch. Both routes enforce
6
- * loopback + same-origin before touching data; nothing here is model-facing.
4
+ * Read-only over SQLite, plus the module switches read through `ui-config.mjs`
5
+ * (the same source the host gates use). Both routes enforce loopback +
6
+ * same-origin before touching data; nothing here is model-facing.
7
7
  */
8
- import fs from 'node:fs';
9
- import path from 'node:path';
8
+ import {
9
+ DEFAULT_MODULES,
10
+ MODULE_KEYS,
11
+ readUiConfig,
12
+ resolveModulesDetail,
13
+ writeUiConfig,
14
+ } from './ui-config.mjs';
10
15
 
11
- const UI_CONFIG_FILE = 'ui-config.json';
12
- const UI_CONFIG_VERSION = 1;
13
16
  const RECENT_LIMIT = 5;
14
17
  const TEXT_LIMIT = 80;
15
18
  const BODY_LIMIT = 4096;
16
19
 
17
- const warn = (logger, message) => {
18
- try {
19
- logger?.warn?.(`[dsh-memsys] ${message}`);
20
- } catch {
21
- // a logger that throws must never break boot
22
- }
23
- };
24
-
25
- /**
26
- * Read the settings file written by the Web switch.
27
- * Returns `{ auto_inject }` or `null` when absent. A corrupt or foreign-version
28
- * file is ignored with one warning: a bad file must never stop the plugin.
29
- */
30
- export function readUiConfig(dataDir, logger = console) {
31
- if (!dataDir) return null;
32
- const file = path.join(dataDir, UI_CONFIG_FILE);
33
- let raw = null;
34
- try {
35
- if (!fs.existsSync(file)) return null;
36
- raw = fs.readFileSync(file, 'utf8');
37
- } catch (err) {
38
- warn(logger, `ui-config 读取失败(${err?.message ?? err}),回落 profile 配置`);
39
- return null;
40
- }
41
- let parsed = null;
42
- try {
43
- parsed = JSON.parse(raw);
44
- } catch {
45
- warn(logger, 'ui-config 不是合法 JSON,回落 profile 配置');
46
- return null;
47
- }
48
- if (parsed?.version !== UI_CONFIG_VERSION) {
49
- warn(logger, `ui-config 版本不符(${parsed?.version ?? 'none'},期望 ${UI_CONFIG_VERSION}),已忽略`);
50
- return null;
51
- }
52
- if (typeof parsed?.auto_inject !== 'boolean') {
53
- warn(logger, 'ui-config 缺少 auto_inject 布尔值,已忽略');
54
- return null;
55
- }
56
- return { auto_inject: parsed.auto_inject };
57
- }
58
-
59
- /**
60
- * Priority: ui-config.json (when present and valid) > profile patch value > false.
61
- * The reported `source` is what the Web page shows next to the switch.
62
- */
63
- export function resolveAutoInject({ dataDir = null, profileValue = undefined, logger = console } = {}) {
64
- const stored = readUiConfig(dataDir, logger);
65
- if (stored !== null) return { value: stored.auto_inject, source: 'ui-config' };
66
- if (typeof profileValue === 'boolean') return { value: profileValue, source: 'profile' };
67
- return { value: false, source: 'default' };
68
- }
69
-
70
- /** Write-through with a temp file so a crash never leaves half a config. */
71
- export function writeUiConfig(dataDir, autoInject) {
72
- if (!dataDir) return false;
73
- fs.mkdirSync(dataDir, { recursive: true });
74
- const target = path.join(dataDir, UI_CONFIG_FILE);
75
- const temp = `${target}.tmp`;
76
- fs.writeFileSync(
77
- temp,
78
- JSON.stringify({ version: UI_CONFIG_VERSION, auto_inject: autoInject === true }, null, 2),
79
- 'utf8',
80
- );
81
- fs.renameSync(temp, target);
82
- return true;
83
- }
84
-
85
20
  const truncate = (text) => Array.from(String(text ?? '')).slice(0, TEXT_LIMIT).join('');
86
21
 
87
- /** Three counts plus the five latest memories; `candidates` means still-open ones. */
88
- export function buildSummary(main, { dataDir = null, autoInject = false, autoInjectSource = 'default' } = {}) {
22
+ /** Three counts, the five latest memories, and the effective module map. */
23
+ export function buildSummary(
24
+ main,
25
+ { dataDir = null, modules = DEFAULT_MODULES, modulesSource = 'default', autoInject = undefined } = {},
26
+ ) {
89
27
  const db = typeof main === 'function' ? main() : main;
90
28
  const one = (sql, ...params) => Number(db.prepare(sql).get(...params)?.n ?? 0);
91
29
  const recent = db
92
30
  .prepare('SELECT id, text FROM memories ORDER BY recorded_at DESC, id DESC LIMIT ?')
93
31
  .all(RECENT_LIMIT)
94
32
  .map((row) => ({ id: row.id, text: truncate(row.text) }));
33
+ const effective = {};
34
+ for (const key of MODULE_KEYS) effective[key] = modules?.[key] !== false;
95
35
  return {
96
36
  memories: one('SELECT COUNT(*) AS n FROM memories'),
97
37
  candidates: one("SELECT COUNT(*) AS n FROM candidates WHERE status IN ('pending', 'conflicted')"),
98
38
  pending_due: one("SELECT COUNT(*) AS n FROM memories WHERE due_state = 'pending-due'"),
99
39
  recent,
100
40
  data_dir: dataDir,
101
- auto_inject: autoInject === true,
102
- auto_inject_source: autoInjectSource,
41
+ auto_inject: autoInject === undefined ? effective.context : autoInject === true,
42
+ auto_inject_source: modulesSource,
43
+ modules: effective,
44
+ modules_source: modulesSource,
103
45
  };
104
46
  }
105
47
 
@@ -128,8 +70,8 @@ export function sameOrigin(req) {
128
70
  export function createSummaryHandler({
129
71
  main,
130
72
  dataDir = null,
131
- getAutoInject = () => false,
132
- onAutoInject = null,
73
+ getConfig = () => ({ modules: DEFAULT_MODULES, source: 'default' }),
74
+ onConfig = null,
133
75
  } = {}) {
134
76
  const send = (res, status, payload) => {
135
77
  res.statusCode = status;
@@ -138,6 +80,16 @@ export function createSummaryHandler({
138
80
  res.end(JSON.stringify(payload));
139
81
  };
140
82
 
83
+ const snapshot = () => {
84
+ const current = getConfig() ?? {};
85
+ const modules = current.modules ?? DEFAULT_MODULES;
86
+ return {
87
+ modules,
88
+ source: current.source ?? 'default',
89
+ auto_inject: modules?.context !== false,
90
+ };
91
+ };
92
+
141
93
  return (req, res) => {
142
94
  if (!isLoopback(req) || !sameOrigin(req)) {
143
95
  send(res, 403, { error: 'forbidden' });
@@ -168,13 +120,31 @@ export function createSummaryHandler({
168
120
  send(res, 400, { error: 'invalid_json' });
169
121
  return;
170
122
  }
171
- if (typeof parsed?.auto_inject !== 'boolean') {
172
- send(res, 400, { error: 'auto_inject_required' });
123
+ const wantsModules = parsed?.modules && typeof parsed.modules === 'object';
124
+ const wantsAutoInject = typeof parsed?.auto_inject === 'boolean';
125
+ if (!wantsModules && !wantsAutoInject) {
126
+ send(res, 400, { error: 'config_required' });
173
127
  return;
174
128
  }
129
+ if (wantsModules) {
130
+ const bad = Object.keys(parsed.modules).filter(
131
+ (key) => MODULE_KEYS.includes(key) && typeof parsed.modules[key] !== 'boolean',
132
+ );
133
+ if (bad.length > 0) {
134
+ send(res, 400, { error: 'module_value_not_boolean', keys: bad });
135
+ return;
136
+ }
137
+ }
175
138
  try {
176
- onAutoInject?.(parsed.auto_inject);
177
- send(res, 200, { ok: true, auto_inject: parsed.auto_inject, auto_inject_source: 'ui-config' });
139
+ const written = onConfig?.({ ...parsed }) ?? null;
140
+ const next = written ?? snapshot();
141
+ send(res, 200, {
142
+ ok: true,
143
+ auto_inject: next.auto_inject,
144
+ auto_inject_source: 'ui-config',
145
+ modules: next.modules,
146
+ modules_source: 'ui-config',
147
+ });
178
148
  } catch (err) {
179
149
  send(res, 500, { error: 'write_failed', message: String(err?.message ?? err) });
180
150
  }
@@ -186,12 +156,21 @@ export function createSummaryHandler({
186
156
  return;
187
157
  }
188
158
  try {
189
- const current = getAutoInject();
190
- const value = typeof current === 'boolean' ? current : current?.value === true;
191
- const source = typeof current === 'boolean' ? 'ui-config' : (current?.source ?? 'default');
192
- send(res, 200, buildSummary(main, { dataDir, autoInject: value, autoInjectSource: source }));
159
+ const state = snapshot();
160
+ send(
161
+ res,
162
+ 200,
163
+ buildSummary(main, {
164
+ dataDir,
165
+ modules: state.modules,
166
+ modulesSource: state.source,
167
+ autoInject: state.auto_inject,
168
+ }),
169
+ );
193
170
  } catch (err) {
194
171
  send(res, 503, { error: 'summary_unavailable', message: String(err?.code ?? err?.message ?? err) });
195
172
  }
196
173
  };
197
174
  }
175
+
176
+ export { readUiConfig, resolveModulesDetail, writeUiConfig };
@@ -246,8 +246,22 @@ function textRender(value) {
246
246
  }
247
247
 
248
248
  /** Plain tool objects before the host wraps them with defineTool. */
249
- export function toolDefinitions(handlers = {}) {
250
- return MEMSYS_TOOLS.map((name) => {
249
+ export function enabledToolNames(modules = null) {
250
+ const enabled = modules ?? { context: true, read: true, write: true, dream: true, projects: true, due: true, feed: true };
251
+ return MEMSYS_TOOLS.filter((name) => {
252
+ if (['memsys_get', 'memsys_search', 'memsys_review_get', 'memsys_context_get', 'memsys_self_get'].includes(name)) {
253
+ return enabled.read !== false;
254
+ }
255
+ if (name === 'memsys_project_get') return enabled.read !== false && enabled.projects !== false;
256
+ if (['memsys_dream_record', 'memsys_organize'].includes(name)) return enabled.dream !== false;
257
+ if (name === 'memsys_feed') return enabled.feed !== false;
258
+ if (name === 'memsys_record') return true; // registered but call-gated for an explicit module_disabled
259
+ return true;
260
+ });
261
+ }
262
+
263
+ export function toolDefinitions(handlers = {}, modules = null) {
264
+ return enabledToolNames(modules).map((name) => {
251
265
  const spec = TOOL_SPECS[name];
252
266
  return {
253
267
  name,
@@ -255,6 +269,9 @@ export function toolDefinitions(handlers = {}) {
255
269
  parameters: spec.parameters,
256
270
  outputSchema: TOOL_OUTPUT_SCHEMAS[name] ?? openObject(),
257
271
  async execute(args, exec) {
272
+ if (name === 'memsys_record' && modules?.write === false) {
273
+ return { status: 'error', code: 'module_disabled', module: 'write' };
274
+ }
258
275
  const handler = handlers[name];
259
276
  if (typeof handler !== 'function') {
260
277
  return { status: 'error', code: 'handler_unavailable', tool: name };
@@ -292,7 +309,7 @@ export function toolCapabilityStatus(liveReport = null) {
292
309
  * Register against a host ctx. No ctx.tools -> tools-only, no throw.
293
310
  * `defineTool` is resolved dynamically so offline tests never need the host package.
294
311
  */
295
- export async function registerHostTools({ ctx = null, handlers = {}, defineTool: injectedDefineTool = null } = {}) {
312
+ export async function registerHostTools({ ctx = null, handlers = {}, modules = null, defineTool: injectedDefineTool = null } = {}) {
296
313
  if (!ctx || !ctx.tools || typeof ctx.tools.register !== 'function') {
297
314
  return { status: 'tools_only', registered: [], reason: 'no_tools_service', defineTool: null, tools: [...MEMSYS_TOOLS] };
298
315
  }
@@ -313,7 +330,7 @@ export async function registerHostTools({ ctx = null, handlers = {}, defineTool:
313
330
  }
314
331
  const registered = [];
315
332
  const errors = [];
316
- for (const spec of toolDefinitions(handlers)) {
333
+ for (const spec of toolDefinitions(handlers, modules)) {
317
334
  try {
318
335
  const tool = defineTool({
319
336
  name: spec.name,
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The single source of truth for module switches.
3
+ *
4
+ * Both halves read this file: the Web data route (`src/runtime/summary.mjs`) and
5
+ * the host-side gates (`src/runtime/modules.mjs` re-exports from here). Nothing
6
+ * else may re-implement the merge — a second copy is how the switch becomes a
7
+ * lie.
8
+ *
9
+ * Priority: `<data_dir>/ui-config.json` > profile patch config > default (true).
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ export const UI_CONFIG_FILE = 'ui-config.json';
15
+ export const UI_CONFIG_VERSION = 2;
16
+ export const MODULE_KEYS = Object.freeze(['context', 'read', 'write', 'dream', 'projects', 'due', 'feed']);
17
+ export const DEFAULT_MODULES = Object.freeze({
18
+ context: true,
19
+ read: true,
20
+ write: true,
21
+ dream: true,
22
+ projects: true,
23
+ due: true,
24
+ feed: true,
25
+ });
26
+
27
+ const warn = (logger, message) => {
28
+ try {
29
+ logger?.warn?.(`[dsh-memsys] ${message}`);
30
+ } catch {
31
+ // a logger that throws must never break boot
32
+ }
33
+ };
34
+
35
+ /** Missing keys mean `true`; only an explicit `false` disables a module. */
36
+ export function normalizeModules(input = {}) {
37
+ const modules = { ...DEFAULT_MODULES };
38
+ if (input && typeof input === 'object') {
39
+ for (const key of MODULE_KEYS) {
40
+ if (typeof input[key] === 'boolean') modules[key] = input[key];
41
+ }
42
+ }
43
+ return modules;
44
+ }
45
+
46
+ /**
47
+ * v2 is `{ version: 2, auto_inject, modules }`.
48
+ * v1 (or versionless) files only carry `auto_inject`; they migrate by mapping it
49
+ * onto `modules.context` while every other module stays on.
50
+ */
51
+ export function normalizeUiConfig(parsed, logger = console) {
52
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
53
+ const version = parsed.version;
54
+ const known = version === undefined || version === 1 || version === UI_CONFIG_VERSION;
55
+ if (!known) {
56
+ warn(logger, `ui-config 版本不认识(${version}),已忽略`);
57
+ return null;
58
+ }
59
+ const hasModules = parsed.modules && typeof parsed.modules === 'object';
60
+ if (version === undefined && typeof parsed.auto_inject !== 'boolean' && !hasModules) {
61
+ warn(logger, 'ui-config 既无 auto_inject 也无 modules,已忽略');
62
+ return null;
63
+ }
64
+ const modules = normalizeModules(hasModules ? parsed.modules : {});
65
+ if (!hasModules && typeof parsed.auto_inject === 'boolean') modules.context = parsed.auto_inject;
66
+ return {
67
+ version: UI_CONFIG_VERSION,
68
+ auto_inject: modules.context,
69
+ modules,
70
+ };
71
+ }
72
+
73
+ export function readUiConfig(dataDir, logger = console) {
74
+ if (!dataDir) return null;
75
+ const file = path.join(dataDir, UI_CONFIG_FILE);
76
+ let raw = null;
77
+ try {
78
+ if (!fs.existsSync(file)) return null;
79
+ raw = fs.readFileSync(file, 'utf8');
80
+ } catch (err) {
81
+ warn(logger, `ui-config 读取失败(${err?.message ?? err}),回落 profile 配置`);
82
+ return null;
83
+ }
84
+ try {
85
+ return normalizeUiConfig(JSON.parse(raw), logger);
86
+ } catch {
87
+ warn(logger, 'ui-config 不是合法 JSON,回落 profile 配置');
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Pure merge used by every caller: defaults <- profile config <- ui-config.
94
+ * A legacy `auto_inject` fills `modules.context` only when that source says
95
+ * nothing about `modules` at all.
96
+ */
97
+ export function mergeModules(profileConfig = null, uiConfig = null) {
98
+ const base = { ...DEFAULT_MODULES };
99
+ const apply = (source) => {
100
+ if (!source || typeof source !== 'object') return;
101
+ const modules = source.modules;
102
+ if (modules && typeof modules === 'object') {
103
+ for (const key of MODULE_KEYS) {
104
+ if (typeof modules[key] === 'boolean') base[key] = modules[key];
105
+ }
106
+ }
107
+ if (typeof source.auto_inject === 'boolean' && modules?.context === undefined) {
108
+ base.context = source.auto_inject;
109
+ }
110
+ };
111
+ apply(profileConfig);
112
+ apply(uiConfig);
113
+ return base;
114
+ }
115
+
116
+ /** Kept under the name the host gates already import. */
117
+ export function resolveModules(profileConfig = null, uiConfig = null) {
118
+ return Object.freeze(mergeModules(profileConfig, uiConfig));
119
+ }
120
+
121
+ export function moduleEnabled(modules, key) {
122
+ return (modules ?? DEFAULT_MODULES)[key] !== false;
123
+ }
124
+
125
+ const profileSaysSomething = (profileConfig) =>
126
+ Boolean(
127
+ profileConfig &&
128
+ (profileConfig.modules && typeof profileConfig.modules === 'object'
129
+ ? MODULE_KEYS.some((key) => typeof profileConfig.modules[key] === 'boolean')
130
+ : typeof profileConfig.auto_inject === 'boolean'),
131
+ );
132
+
133
+ /**
134
+ * Resolve for the Web route: returns the effective map plus which rank won,
135
+ * so the settings page can show where the value came from.
136
+ */
137
+ export function resolveModulesDetail({ dataDir = null, profileConfig = null, logger = console } = {}) {
138
+ const stored = readUiConfig(dataDir, logger);
139
+ const modules = mergeModules(profileConfig, stored);
140
+ const source = stored ? 'ui-config' : profileSaysSomething(profileConfig) ? 'profile' : 'default';
141
+ return { modules, source, auto_inject: modules.context };
142
+ }
143
+
144
+ /**
145
+ * Partial update: sends only the keys it owns. A legacy `auto_inject` maps onto
146
+ * `modules.context`; sending `modules.context` mirrors back into `auto_inject`
147
+ * so v1 readers keep working.
148
+ */
149
+ export function writeUiConfig(dataDir, patch = {}, logger = console) {
150
+ if (!dataDir) return null;
151
+ const current = readUiConfig(dataDir, logger) ?? {
152
+ version: UI_CONFIG_VERSION,
153
+ auto_inject: DEFAULT_MODULES.context,
154
+ modules: { ...DEFAULT_MODULES },
155
+ };
156
+ const next = { ...current.modules };
157
+ if (patch.modules && typeof patch.modules === 'object') {
158
+ for (const key of MODULE_KEYS) {
159
+ if (typeof patch.modules[key] === 'boolean') next[key] = patch.modules[key];
160
+ }
161
+ }
162
+ if (typeof patch.auto_inject === 'boolean' && patch.modules?.context === undefined) {
163
+ next.context = patch.auto_inject;
164
+ }
165
+ const normalized = {
166
+ version: UI_CONFIG_VERSION,
167
+ auto_inject: next.context,
168
+ modules: next,
169
+ };
170
+ fs.mkdirSync(dataDir, { recursive: true });
171
+ const target = path.join(dataDir, UI_CONFIG_FILE);
172
+ const temp = `${target}.tmp`;
173
+ fs.writeFileSync(temp, JSON.stringify(normalized, null, 2), 'utf8');
174
+ fs.renameSync(temp, target);
175
+ return normalized;
176
+ }
@@ -76,7 +76,13 @@ test('P4/P4-B E10: receipts must match the persistent manifest and groups', (t)
76
76
  receivedIds: expected,
77
77
  payloadHash,
78
78
  });
79
- assert.equal(full.delivery, 'confirmed', JSON.stringify(full));
79
+ if (compiled.manifest.actual_ids.filter((id) => id.startsWith('mem_')).length === 0) {
80
+ // Design §6.2c: an empty actual set can never be confirmed, even if the receipt equals it.
81
+ assert.equal(full.delivery, 'incomplete', JSON.stringify(full));
82
+ assert.equal(full.code, 'receipt_incomplete');
83
+ } else {
84
+ assert.equal(full.delivery, 'confirmed', JSON.stringify(full));
85
+ }
80
86
  const exposure = ctx.main.prepare('SELECT memory_id FROM injections').all().map((row) => row.memory_id);
81
87
  assert.equal(exposure.length, 0, 'deferred members must not produce exposure');
82
88
  });
@@ -0,0 +1,69 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { recordDelivery } from '../src/context/delivery.mjs';
5
+ import { getMemories } from '../src/read/get.mjs';
6
+ import {
7
+ insertCandidate, insertEvidence, insertMemory, insertObservation, insertPointer, insertTimeline,
8
+ } from '../src/storage/repositories.mjs';
9
+ import { createTestContext, makeCandidate, makeMemory, makeObservation, mutableClock } from './helpers/fixtures.mjs';
10
+
11
+ function deliveryFixture(ctx, { actualIds, expectedIds, receivedIds, hash = 'h' }) {
12
+ const roundId = 'r-c1';
13
+ const snapshotId = 'snap-c1';
14
+ ctx.control
15
+ .prepare("INSERT OR REPLACE INTO compilations (id, round_id, stream_key, source_revision, policy_revision, delivery_level, payload_hash, section_text, created_at) VALUES (?, ?, 'stream', 0, '1', 'grouped', ?, '', ?)")
16
+ .run(`comp:${snapshotId}`, roundId, hash, ctx.clock.at().at);
17
+ ctx.control
18
+ .prepare("INSERT OR REPLACE INTO context_snapshots (id, compilation_id, round_id, mode, source_revision, policy_revision, delivery_level, eligible_ids, expected_ids, deferred_groups, created_at, expires_at, manifest_json) VALUES (?, ?, ?, 'work', 0, '1', 'grouped', '[]', ?, '[]', ?, ?, ?)")
19
+ .run(snapshotId, `comp:${snapshotId}`, roundId, JSON.stringify(expectedIds), ctx.clock.at().at, ctx.clock.at().at, JSON.stringify({ expected_ids: expectedIds, actual_ids: actualIds, hashes: { payload: hash } }));
20
+ return recordDelivery({ main: ctx.main, control: ctx.control, clock: ctx.clock, roundId, snapshotId, receivedIds, payloadHash: hash, manifestHash: 'm', slots: {} });
21
+ }
22
+
23
+ test('P6/C1: group-only receipt with empty actual set is incomplete', (t) => {
24
+ const ctx = createTestContext({ clock: mutableClock().clock });
25
+ t.after(() => ctx.cleanup());
26
+ const result = deliveryFixture(ctx, { actualIds: [], expectedIds: ['group:g1'], receivedIds: ['group:g1'] });
27
+ assert.equal(result.delivery, 'incomplete');
28
+ assert.equal(result.code, 'receipt_incomplete');
29
+ assert.notEqual(result.delivery, 'confirmed');
30
+ });
31
+
32
+ test('P6/C1: non-empty actual set missing one memory is incomplete', (t) => {
33
+ const ctx = createTestContext({ clock: mutableClock().clock });
34
+ t.after(() => ctx.cleanup());
35
+ const result = deliveryFixture(ctx, { actualIds: ['mem_a', 'mem_b'], expectedIds: ['mem_a', 'mem_b', 'group:g1'], receivedIds: ['mem_a', 'group:g1'] });
36
+ assert.equal(result.delivery, 'incomplete');
37
+ assert.equal(result.code, 'receipt_incomplete');
38
+ assert.deepEqual(result.missing_ids, ['mem_b']);
39
+ });
40
+
41
+ test('P6/C4: evidence text pages reassemble byte-for-byte', (t) => {
42
+ const ctx = createTestContext({ clock: mutableClock().clock });
43
+ t.after(() => ctx.cleanup());
44
+ const original = '证据原文'.repeat(600);
45
+ const observation = makeObservation(ctx, { messageSeq: 30000, text: original });
46
+ insertObservation(ctx.main, observation);
47
+ const candidate = makeCandidate(ctx, observation, { businessKey: 'c4', text: '短摘要', why: 'w', type: 'fact', scene: 'work', scope: 'work-only' });
48
+ insertCandidate(ctx.main, candidate);
49
+ const memory = makeMemory(ctx, candidate);
50
+ insertMemory(ctx.main, memory);
51
+ insertTimeline(ctx.main, { memory_id: memory.id, start_at: null, end_at: null, prec_start: 'unknown', prec_end: 'unknown', time_state: 'point', revision: 1 });
52
+ insertPointer(ctx.main, { memory_id: memory.id, status: 'unknown', location: null, search_hint: null, lossy: 0, revision: 1 });
53
+ insertEvidence(ctx.main, { id: 'ev_c4', memory_id: memory.id, candidate_id: null, observation_id: observation.id, source_memory_id: null, role: 'basis', source_span: 'bytes:0:4', at: ctx.clock.at().at });
54
+
55
+ let cursor = null;
56
+ let joined = '';
57
+ for (let page = 0; page < 20; page += 1) {
58
+ const result = getMemories({ main: ctx.main, clock: ctx.clock, ids: [memory.id], includeEvidence: true, maxChars: 1000, cursor });
59
+ assert.equal(result.status, 'ok', JSON.stringify(result));
60
+ for (const item of result.items) {
61
+ for (const evidence of item.evidence ?? []) {
62
+ if (typeof evidence.observation_text === 'string') joined += evidence.observation_text;
63
+ }
64
+ }
65
+ cursor = result.next_cursor;
66
+ if (!cursor) break;
67
+ }
68
+ assert.equal(joined, original);
69
+ });
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { compileRound } from '../src/context/compile.mjs';
5
+ import { conservativeTokens } from '../src/context/budget.mjs';
6
+ import { getGroupPage } from '../src/context/snapshots.mjs';
7
+ import { ingestInput } from '../src/write/observe.mjs';
8
+ import { promoteCandidate, recordCandidates } from '../src/write/candidate.mjs';
9
+ import { createTestContext, mutableClock } from './helpers/fixtures.mjs';
10
+ import { fakeRuntime } from './helpers/runtime.mjs';
11
+
12
+ let seq = 0;
13
+ function addMemory(ctx, text) {
14
+ seq += 1;
15
+ const runtime = fakeRuntime({ role: 'daily', identity: 'c3a', callId: 'c3a-' + seq, messageSeq: 31000 + seq });
16
+ const observation = ingestInput(runtime, { text: 'C3-' + seq, messageSeq: 31000 + seq }, { main: ctx.main, control: ctx.control, clock: ctx.clock });
17
+ const created = recordCandidates(runtime, {
18
+ items: [{
19
+ observationId: observation.observation.id,
20
+ text,
21
+ why: 'w', type: 'fact', scene: 'work', scope: 'work-only',
22
+ evidenceRefs: [{ observationId: observation.observation.id, sourceSpan: 'bytes:0:3' }],
23
+ }],
24
+ }, { main: ctx.main, control: ctx.control, clock: ctx.clock });
25
+ promoteCandidate(runtime, created.items[0].id, null, { main: ctx.main, control: ctx.control, clock: ctx.clock });
26
+ }
27
+
28
+ function make(ctx) {
29
+ for (let i = 0; i < 12; i += 1) addMemory(ctx, 'C3混版-' + i + '-' + 'x'.repeat(500));
30
+ }
31
+
32
+ function compile(ctx, roundId) {
33
+ return compileRound({ main: ctx.main, control: ctx.control, clock: ctx.clock, sessionId: 's-c3a', roundId, mode: 'work', tokenizer: conservativeTokens, H: 300 });
34
+ }
35
+
36
+ function page(ctx, snapshotRevision, groupId, cursor = null) {
37
+ return getGroupPage({ control: ctx.control, main: ctx.main, clock: ctx.clock, snapshotRevision, groupId, cursor, maxChars: 1000 });
38
+ }
39
+
40
+ test('P6/C3: old cursor is stale after an append through the promote path', (t) => {
41
+ const ctx = createTestContext({ clock: mutableClock().clock });
42
+ t.after(() => ctx.cleanup());
43
+ make(ctx);
44
+ const first = compile(ctx, 'r-c3a');
45
+ assert.equal(first.delivery_level, 'grouped', JSON.stringify(first.delivery_level));
46
+ const groupId = first.groups[0].group_id;
47
+ const firstPage = page(ctx, first.snapshot_revision, groupId);
48
+ assert.equal(firstPage.status, 'ok', JSON.stringify(firstPage));
49
+ assert.ok(firstPage.next_cursor);
50
+ addMemory(ctx, '追加混版-' + 'y'.repeat(500));
51
+ const stale = page(ctx, first.snapshot_revision, groupId, firstPage.next_cursor);
52
+ assert.equal(stale.status, 'error', JSON.stringify(stale));
53
+ assert.equal(stale.code, 'stale_cursor');
54
+ });
55
+
56
+ test('P6/C3: same data recompiles to same id; append changes it', (t) => {
57
+ const ctx = createTestContext({ clock: mutableClock().clock });
58
+ t.after(() => ctx.cleanup());
59
+ make(ctx);
60
+ const a = compile(ctx, 'r-c3b1');
61
+ const b = compile(ctx, 'r-c3b2');
62
+ assert.equal(a.snapshot_revision, b.snapshot_revision);
63
+ addMemory(ctx, '追加换ID-' + 'z'.repeat(500));
64
+ const c = compile(ctx, 'r-c3b3');
65
+ assert.notEqual(c.snapshot_revision, a.snapshot_revision);
66
+ });
67
+
68
+ test('P6/C3: clean paging reaches the end without stale_cursor', (t) => {
69
+ const ctx = createTestContext({ clock: mutableClock().clock });
70
+ t.after(() => ctx.cleanup());
71
+ make(ctx);
72
+ const compiled = compile(ctx, 'r-c3c');
73
+ const groupId = compiled.groups[0].group_id;
74
+ let cursor = null;
75
+ let pages = 0;
76
+ for (;;) {
77
+ const result = page(ctx, compiled.snapshot_revision, groupId, cursor);
78
+ assert.equal(result.status, 'ok', JSON.stringify(result));
79
+ pages += 1;
80
+ cursor = result.next_cursor;
81
+ if (!cursor) break;
82
+ assert.ok(pages < 100);
83
+ }
84
+ assert.ok(pages >= 1);
85
+ });