dsh-memsys 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,9 +10,18 @@ node scripts/live-check.mjs --case tools # 真机:插件能否装进宿
10
10
 
11
11
  已实测(隔离 profile,带 key):插件加载 → 10 个 `memsys_*` 工具注册进宿主 → 模型调用 `memsys_search` 返回真实命中 → 模型调用 `memsys_record` 写入候选与记忆,`origin.author_kind=model`、会话与调用 ID 与宿主记录一致。
12
12
 
13
- ## 从 GitHub 安装
13
+ ## 安装
14
14
 
15
- **尚未发布到 npm**,所以 `npm install dsh-memsys` 装不到。现在的装法是先克隆再让 DSH 从本地路径安装:
15
+ **已发布到 npm**:`dsh-memsys@0.1.0`(https://www.npmjs.com/package/dsh-memsys)。
16
+
17
+ 装进一个**独立 profile**(不要直接装进你日常用的那个):
18
+
19
+ ```bash
20
+ dsh --profile memsys headless --from-default-profile --dump-config # 初始化隔离 profile
21
+ dsh plugin --profile memsys add dsh-memsys # 从 npm 装
22
+ ```
23
+
24
+ 或者不依赖 npm,从源码装:
16
25
 
17
26
  ```bash
18
27
  git clone https://github.com/tok-ryan/dsh-memsys.git dsh-memsys
@@ -20,10 +29,7 @@ cd dsh-memsys
20
29
  npm test # 先跑一遍,确认本地环境能过(Node >= 22.13)
21
30
  ```
22
31
 
23
- 然后用一个**独立 profile** 装进去(不要直接装进你日常用的那个):
24
-
25
32
  ```bash
26
- dsh --profile memsys headless --from-default-profile --dump-config # 初始化一个隔离 profile
27
33
  dsh plugin --profile memsys add "file:///全/路径/dsh-memsys" # 按实际路径改
28
34
  ```
29
35
 
@@ -57,9 +63,30 @@ node scripts/live-check.mjs --case tools --profile memsys # 真机:插件
57
63
 
58
64
  **设计原则**:事实只追加;当前状态是可重建投影;模型只能提交候选,不能直接落事实;一切写后读回按固化预期比对,不是"行还在就算成功"。
59
65
 
60
- ## 状态
66
+ ## 界面(0.2.0 起)
67
+
68
+ 装上之后 Web GUI 里会多三样东西,都来自同一个浏览器半 `client.js`:
69
+
70
+ - **左侧栏「🐳 记忆」**:点开是一块面板——记忆条数 / 候选条数 / 待确认惦记数,加最近 5 条记忆正文(超长截断)
71
+ - **设置 →「记忆系统」**:`auto_inject` 开关 + 当前数据目录;开关改完**立即生效**(重挂/撤下 system prompt 里的记忆段),刷新后仍在
72
+ - 面板与设置页共用数据口 `/dsh-memsys/api/*`(loopback + 同源校验;跨源 403)
73
+
74
+ **设置优先级**:`<data_dir>/ui-config.json`(存在且合法)> profile 的 `config` > 默认全开。
75
+ `/api/summary` 会返回 `modules` / `modules_source`,告诉你每个开关的当前值和来源(`ui-config` / `profile` / `default`)。
76
+
77
+ **注意生效时机**:自动注入、写入工具**改完立即生效**;读取工具 / 做梦 / 项目 / 待确认惦记 / 信息流是**下次启动生效**(工具在启动时一次性注册,宿主没有注销接口)。
78
+
79
+ **headless 不受影响**:没有 `webServer` 时那段注册不激活,浏览器半也不加载。
80
+
81
+ ## 状态(0.2.1)
82
+
83
+ - **读**:真机 `dispatch=ok`,`direct_hit_count: 2` 返回真实命中
84
+ - **写**:真机 `record=ok`,库内落 candidate + memory,`origin.author_kind=model`、`call_id` 与宿主记录一致
85
+ - **注入**:`compileRound` 已接进运行时,section 文本是**当轮编译出来的记忆目录**(`text` 为函数,每步 `assemble()` 重取);无 tokenizer/H 时退回 tools-only,不假装投递(`delivery=unknown`)
86
+ - **界面**:左侧栏「记忆」入口 + 面板(三数字 + 最近 5 条)+ 设置页七行模块开关
87
+ - **测试**:356 项全绿
61
88
 
62
- 已实现并在真机验证**读**与**写**;仍有 6 条设计条款未达标(合并时 due 语义、回退证明深度、门牌与骨架核验、混版分页测试等)。逐条列在本文件末尾"已知边界"一节,不粉饰。
89
+ **仍不达标**:`L6` 只核到内容 hash 与关联,**没有逐项重放 relations 内容**;真实旧库尚未导入;生产 cutover 未跑;`feed` 生产 provider 未接。见文末"已知边界"。
63
90
 
64
91
  MIT License。
65
92
 
package/client.js ADDED
@@ -0,0 +1,137 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-memsys',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+ const NS = 'dsh-memsys';
7
+ const API = '/dsh-memsys/api/summary';
8
+ const CONFIG = '/dsh-memsys/api/config';
9
+
10
+ function useSummary() {
11
+ const [state, setState] = React.useState(null);
12
+ React.useEffect(() => {
13
+ let alive = true;
14
+ const load = () => {
15
+ fetch(API)
16
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(String(response.status)))))
17
+ .then((data) => alive && setState(data))
18
+ .catch(() => alive && setState({ error: true }));
19
+ };
20
+ load();
21
+ const timer = setInterval(load, 15000);
22
+ return () => {
23
+ alive = false;
24
+ clearInterval(timer);
25
+ };
26
+ }, []);
27
+ return [state, setState];
28
+ }
29
+
30
+ function MemoryPanel() {
31
+ const [state] = useSummary();
32
+ if (!state) return h('p', { style: { padding: 16 } }, '读取记忆…');
33
+ if (state.error) return h('p', { style: { padding: 16 } }, '暂时读不到记忆数据。');
34
+ return h(
35
+ 'div',
36
+ { style: { padding: 16, lineHeight: 1.7 } },
37
+ h('h2', { style: { margin: '0 0 8px' } }, '记忆'),
38
+ h('p', null, `记忆 ${state.memories} 条 · 候选 ${state.candidates} 条 · 待确认惦记 ${state.pending_due} 条`),
39
+ h('h3', { style: { margin: '16px 0 6px' } }, '最近 5 条'),
40
+ state.recent.length === 0
41
+ ? h('p', null, '还没有记忆。')
42
+ : h('ul', null, state.recent.map((memory) => h('li', { key: memory.id }, memory.text))),
43
+ );
44
+ }
45
+
46
+ function MemoryIcon() {
47
+ return h('span', { 'aria-hidden': true, role: 'img', title: '记忆' }, '🐳');
48
+ }
49
+
50
+ const SOURCE_LABEL = { 'ui-config': '设置文件', profile: 'profile 配置', default: '默认值' };
51
+
52
+ // key, 名字, 一句人话, 生效时机(别糊成"立即生效",四个模块真的要重启)
53
+ const MODULE_ROWS = [
54
+ ['context', '自动注入', '每轮把记忆摘要注入系统提示;关掉后只能手动查', '改完立即生效'],
55
+ ['read', '读取工具', '关掉后查询类工具(get / search / review)不可用', '下次启动生效'],
56
+ ['write', '写入工具', '关掉后不再记录新记忆,候选也收', '改完立即生效'],
57
+ ['dream', '做梦', '关掉后不浮现、也不能记录梦', '下次启动生效'],
58
+ ['projects', '项目', '关掉后项目视角不可用', '下次启动生效'],
59
+ ['due', '待确认惦记', '关掉后不提醒到期的惦记,也不能确认或搁置', '下次启动生效'],
60
+ ['feed', '信息流', '关掉后不产出信息流候选', '下次启动生效'],
61
+ ];
62
+
63
+ function MemorySettings() {
64
+ const [state, setState] = React.useState(null);
65
+ const [note, setNote] = React.useState('');
66
+ React.useEffect(() => {
67
+ fetch(API)
68
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(String(response.status)))))
69
+ .then(setState)
70
+ .catch(() => setState({ error: true }));
71
+ }, []);
72
+ const modules = state?.modules ?? {};
73
+ const save = (key, value) => {
74
+ const previous = modules[key] !== false;
75
+ setState((current) => ({ ...(current ?? {}), modules: { ...(current?.modules ?? {}), [key]: value } }));
76
+ setNote('保存中…');
77
+ fetch(CONFIG, {
78
+ method: 'POST',
79
+ headers: { 'content-type': 'application/json' },
80
+ body: JSON.stringify({ modules: { [key]: value } }),
81
+ })
82
+ .then((response) =>
83
+ response.ok
84
+ ? response.json().then((body) => setState((current) => ({ ...(current ?? {}), ...body })))
85
+ : Promise.reject(new Error(String(response.status))),
86
+ )
87
+ .then(() => setNote('已保存'))
88
+ .catch(() => {
89
+ setState((current) => ({ ...(current ?? {}), modules: { ...(current?.modules ?? {}), [key]: previous } }));
90
+ setNote('保存失败');
91
+ });
92
+ };
93
+ const disabled = !state || state.error === true;
94
+ return h(
95
+ 'div',
96
+ { style: { padding: '8px 0', lineHeight: 1.7 } },
97
+ h('p', { style: { margin: '0 0 4px', opacity: 0.75 } }, '按模块开关,默认全开。关掉哪一项,那一项就不再工作。'),
98
+ ...MODULE_ROWS.map(([key, label, hint, timing]) =>
99
+ h(
100
+ 'label',
101
+ { key, style: { display: 'block', margin: '6px 0' } },
102
+ h('span', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
103
+ h('input', {
104
+ type: 'checkbox',
105
+ checked: modules[key] !== false,
106
+ disabled,
107
+ onChange: (event) => save(key, event.target.checked),
108
+ }),
109
+ label,
110
+ ),
111
+ h('span', { style: { display: 'block', marginLeft: 24, opacity: 0.7, fontSize: '0.92em' } },
112
+ `${hint}(${timing})`),
113
+ ),
114
+ ),
115
+ h('p', { style: { opacity: 0.75, margin: '10px 0 0' } }, `数据目录:${state?.data_dir ?? '未配置'}`),
116
+ h('p', { style: { opacity: 0.75, margin: '4px 0 0' } },
117
+ `当前生效值来源:${SOURCE_LABEL[state?.modules_source] ?? state?.modules_source ?? '未知'}`),
118
+ note ? h('p', { style: { opacity: 0.75, margin: '4px 0 0' } }, note) : null,
119
+ );
120
+ }
121
+
122
+ return {
123
+ inject: ['slots'],
124
+ apply(ctx) {
125
+ ctx.slots.inject('main', () =>
126
+ ctx.slots.register({ name: 'main', key: NS }, MemoryPanel),
127
+ );
128
+ ctx.slots.inject('sidebar.panellist', () =>
129
+ ctx.slots.register({ name: 'sidebar.panellist', id: NS, order: 30, label: () => '记忆' }, MemoryIcon),
130
+ );
131
+ ctx.slots.inject('settings.section', () =>
132
+ ctx.slots.register({ name: 'settings.section', id: NS, order: 40, label: () => '记忆系统' }, MemorySettings),
133
+ );
134
+ },
135
+ };
136
+ },
137
+ });
package/index.mjs CHANGED
@@ -12,13 +12,81 @@ import { createFeedHandler } from './src/tools/feed.mjs';
12
12
  import { createSelfHandler } from './src/tools/self.mjs';
13
13
  import { createRecordHandler } from './src/tools/record.mjs';
14
14
  import { runtimeFromExec } from './src/runtime/identity.mjs';
15
+ import { createSummaryHandler } from './src/runtime/summary.mjs';
16
+ import { mergeModules, resolveModulesDetail, writeUiConfig } from './src/runtime/ui-config.mjs';
17
+ import { createRoundInjector } from './src/runtime/inject-round.mjs';
18
+ import { compileRound } from './src/context/compile.mjs';
15
19
 
16
20
  export const name = 'dsh-memsys';
17
21
  /** tools is a real host service; static inject is the verified registration path. */
18
22
  export const inject = ['tools'];
19
23
 
20
- function createHostHandlers(config = {}, ctx = null) {
24
+ /**
25
+ * Module switches have three ranks, highest first:
26
+ * 1. `<data_dir>/ui-config.json` — written by the settings page;
27
+ * 2. the profile patch config (`auto_inject` / `modules`), which is itself a
28
+ * per-key overlay on the default map;
29
+ * 3. every module on.
30
+ * The file wins so a toggle survives a restart without rewriting the profile,
31
+ * and the in-memory map is applied immediately (no restart).
32
+ */
33
+ function createUiState(config = {}, logger = console) {
21
34
  const dataDir = config.data_dir ?? process.env.MEMSYS_DATA_DIR ?? null;
35
+ const detail = resolveModulesDetail({ dataDir, profileConfig: config, logger });
36
+ // One stable object: host tools capture it at registration, so a POST has to
37
+ // mutate it in place or the call-time gates would keep reading stale values.
38
+ const modules = { ...detail.modules };
39
+ let source = detail.source;
40
+ return {
41
+ dataDir,
42
+ get modules() {
43
+ return modules;
44
+ },
45
+ get source() {
46
+ return source;
47
+ },
48
+ get autoInject() {
49
+ return modules.context !== false;
50
+ },
51
+ apply(written) {
52
+ Object.assign(modules, written.modules);
53
+ source = 'ui-config';
54
+ },
55
+ sync: () => {},
56
+ };
57
+ }
58
+
59
+ function createHostHandlers(config = {}, ctx = null, ui = null) {
60
+ const dataDir = config.data_dir ?? process.env.MEMSYS_DATA_DIR ?? null;
61
+ const uiState = ui ?? {
62
+ get modules() {
63
+ return mergeModules(config, null);
64
+ },
65
+ get source() {
66
+ return typeof config.auto_inject === 'boolean' || config.modules ? 'profile' : 'default';
67
+ },
68
+ get autoInject() {
69
+ return mergeModules(config, null).context !== false;
70
+ },
71
+ apply() {},
72
+ sync: () => {},
73
+ };
74
+ const uiSummary = createSummaryHandler({
75
+ main: () => db().main,
76
+ dataDir,
77
+ getConfig: () => ({ modules: uiState.modules, source: uiState.source }),
78
+ onConfig: (patch) => {
79
+ if (!dataDir) {
80
+ const err = new Error('data_dir_missing');
81
+ err.code = 'data_dir_missing';
82
+ throw err;
83
+ }
84
+ const written = writeUiConfig(dataDir, patch);
85
+ uiState.apply(written);
86
+ uiState.sync();
87
+ return { auto_inject: written.auto_inject, modules: written.modules };
88
+ },
89
+ });
22
90
  let handles = null;
23
91
  const db = () => {
24
92
  if (!dataDir) {
@@ -67,6 +135,7 @@ function createHostHandlers(config = {}, ctx = null) {
67
135
  memsys_project_get: (args) => createProjectHandler({ main: db().main, clock: systemClock }).get(args),
68
136
  memsys_self_get: (args) => createSelfHandler({ main: db().main, clock: systemClock }).get(args),
69
137
  memsys_feed: (args) => createFeedHandler({ clock: systemClock }).feed(args),
138
+ memsys_ui_summary: (req, res) => uiSummary(req, res),
70
139
  memsys_record: (args, exec) => {
71
140
  const identity = runtimeFromExec({ exec, ctx, tool: 'record', args });
72
141
  if (!identity.ok) return { status: 'error', code: identity.code, ...identity };
@@ -94,8 +163,39 @@ function createHostHandlers(config = {}, ctx = null) {
94
163
  * section exists only for the isolated probe profile.
95
164
  */
96
165
  export async function apply(ctx, config = {}) {
97
- const handlers = createHostHandlers(config, ctx);
98
- const registration = await registerHostTools({ ctx, handlers });
166
+ const ui = createUiState(config);
167
+ const handlers = createHostHandlers(config, ctx, ui);
168
+ const registration = await registerHostTools({ ctx, handlers, modules: ui.modules });
169
+
170
+ // The round injector owns the session/turn subscription; the section only
171
+ // reads its current text. Verified empirically: a section's `text` function is
172
+ // re-evaluated on every `systemPrompt.assemble()` (once per model step), so no
173
+ // per-turn re-mount is needed — a toggle only has to change what `current()` says.
174
+ let injector = null;
175
+ const syncInjector = () => {
176
+ if (ui.modules.context === false || !ui.dataDir) {
177
+ injector?.dispose?.();
178
+ injector = null;
179
+ return;
180
+ }
181
+ if (!injector) {
182
+ injector = createRoundInjector({
183
+ ctx,
184
+ dataDir: ui.dataDir,
185
+ modules: ui.modules,
186
+ compileRound,
187
+ tokenizer: config.tokenizer ?? null,
188
+ H: config.H ?? null,
189
+ mode: config.mode ?? 'work',
190
+ });
191
+ try {
192
+ injector.refresh({});
193
+ } catch {
194
+ // a failed first compile must not stop the host half from booting
195
+ }
196
+ }
197
+ };
198
+ syncInjector();
99
199
  const toolProbe = process.env.MEMSYS_TOOL_PROBE ?? null;
100
200
  const writeToolProbe = (deferred) => {
101
201
  if (!toolProbe) return;
@@ -235,21 +335,62 @@ export async function apply(ctx, config = {}) {
235
335
  write();
236
336
  }
237
337
 
238
- const autoInject = config.auto_inject === true;
338
+ if (typeof ctx?.inject === 'function') {
339
+ try {
340
+ ctx.inject(['webServer'], (web) => {
341
+ web.effect(
342
+ () =>
343
+ web.webServer.register({
344
+ kind: 'exact',
345
+ path: '/dsh-memsys/api/summary',
346
+ handler: (req, res) => handlers.memsys_ui_summary(req, res),
347
+ }),
348
+ 'memsys: ui summary route',
349
+ );
350
+ web.effect(
351
+ () =>
352
+ web.webServer.register({
353
+ kind: 'exact',
354
+ path: '/dsh-memsys/api/config',
355
+ handler: (req, res) => handlers.memsys_ui_summary(req, res),
356
+ }),
357
+ 'memsys: ui config route',
358
+ );
359
+ });
360
+ } catch {
361
+ // profiles without webServer stay headless
362
+ }
363
+ }
239
364
  const diagnostic = config.diagnostic_section === true;
240
365
  let disposeSection = () => {};
241
- if (autoInject || diagnostic) {
242
- const getText = () =>
243
- autoInject
244
- ? '[memsys] 注入:tools-only(本批未取得宿主预算与回执,不作逐轮交付承诺)'
245
- : '[memsys] diagnostic section(auto_inject=false)';
366
+ const syncSection = () => {
367
+ disposeSection();
368
+ disposeSection = () => {};
369
+ // The section is owned by the `context` module; the probe profile may force
370
+ // a diagnostic section even while injection is off.
371
+ if (ui.modules.context === false && !diagnostic) return;
246
372
  disposeSection = registerSection(ctx, {
247
- getText,
373
+ getText: () =>
374
+ ui.modules.context === false
375
+ ? '[memsys] diagnostic section(context=false)'
376
+ : (injector?.current().text ?? ''),
377
+ dynamicText: true,
248
378
  diagnosticMarker: process.env.MEMSYS_SECTION_PROBE ?? null,
249
379
  });
250
- }
380
+ };
381
+ // A profile without systemPrompt (headless/embedded) must still boot the tools.
382
+ ui.sync = () => {
383
+ try {
384
+ syncInjector();
385
+ syncSection();
386
+ } catch {
387
+ // keep the host half alive; the section is optional
388
+ }
389
+ };
390
+ ui.sync();
251
391
  return () => {
252
392
  disposeSection();
393
+ injector?.dispose?.();
253
394
  };
254
395
  }
255
396
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memsys",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A memory system plugin for DeepSeek Harness: append-only facts, candidates and adjudication, dreams, and budget-aware context injection.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,15 +28,22 @@
28
28
  "node": ">=22.13"
29
29
  },
30
30
  "exports": {
31
- ".": "./index.mjs"
31
+ ".": "./index.mjs",
32
+ "./client": "./client.js"
32
33
  },
33
34
  "dsh": {
34
35
  "bundle": {
35
36
  "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "platform": "web",
40
+ "immediately": true,
41
+ "inject": []
36
42
  }
37
43
  },
38
44
  "files": [
39
45
  "index.mjs",
46
+ "client.js",
40
47
  "cordis.patch.yml",
41
48
  "src",
42
49
  "scripts",
package/scripts/build.mjs CHANGED
@@ -12,7 +12,8 @@ function walk(dir, out = []) {
12
12
  if (SKIP_DIRS.has(entry.name)) continue;
13
13
  const full = path.join(dir, entry.name);
14
14
  if (entry.isDirectory()) walk(full, out);
15
- else if (entry.name.endsWith('.mjs')) out.push(full);
15
+ else if (entry.name.endsWith('.mjs') || (entry.name.endsWith('.js') && entry.name !== 'client.js')) out.push(full);
16
+ else if (entry.name === 'client.js') out.push(full);
16
17
  }
17
18
  return out;
18
19
  }
@@ -178,8 +178,10 @@ async function main() {
178
178
  required(deploymentDir, 'USAGE: activate --plan <plan.json> --data-dir <dir> --restore-report <report.json> --deployment-dir <dir>');
179
179
  const activateTarget = openTarget(dataDir);
180
180
  let businessRows = null;
181
+ let databaseFreeze = null;
181
182
  try {
182
183
  businessRows = rollback.snapshotBusinessRows(activateTarget.handles.main);
184
+ databaseFreeze = rollback.snapshotDatabaseFreeze(activateTarget.handles);
183
185
  } finally {
184
186
  closeAll(activateTarget.handles);
185
187
  }
@@ -193,6 +195,7 @@ async function main() {
193
195
  restoreBatch: restore.selected_batch,
194
196
  business_counts: businessRows?.counts ?? null,
195
197
  business_rows: businessRows,
198
+ db_freeze: databaseFreeze,
196
199
  activatedAt: systemClock.at().at,
197
200
  }, null, 2)}\n`,
198
201
  'utf8',
@@ -225,6 +228,7 @@ async function main() {
225
228
  }
226
229
  const restored = openAll(resolveConfig({ dataDir: restoredDir }));
227
230
  let comparison;
231
+ let freezeComparison = { ok: true, missing: [] };
228
232
  try {
229
233
  comparison = config.business_rows
230
234
  ? rollback.compareBusinessRows({ db: restored.main, baseline: config.business_rows })
@@ -232,11 +236,17 @@ async function main() {
232
236
  db: restored.main,
233
237
  expected: config.business_counts ?? {},
234
238
  });
239
+ if (config.db_freeze) {
240
+ freezeComparison = rollback.compareDatabaseFreeze(
241
+ { main: restored.main, control: restored.control, dream: restored.dream },
242
+ config.db_freeze,
243
+ );
244
+ }
235
245
  } finally {
236
246
  closeAll(restored);
237
247
  }
238
- if (!comparison.ok) {
239
- console.log(`ROLLBACK_CHECK_FAILED business_missing=${comparison.missing.join(',')}`);
248
+ if (!comparison.ok || !freezeComparison.ok) {
249
+ console.log(`ROLLBACK_CHECK_FAILED business_missing=${comparison.missing.join(',')} freeze_missing=${freezeComparison.missing.join(',')}`);
240
250
  process.exitCode = 1;
241
251
  return;
242
252
  }
@@ -125,7 +125,7 @@ record('profile_prepared', dumpBefore.status === 0, {
125
125
  // 2. Stage an ASCII copy; the live source tree is never linked into a profile.
126
126
  fs.rmSync(STAGE, { recursive: true, force: true });
127
127
  fs.mkdirSync(STAGE, { recursive: true });
128
- for (const name of ['package.json', 'index.mjs', 'cordis.patch.yml']) {
128
+ for (const name of ['package.json', 'index.mjs', 'client.js', 'cordis.patch.yml']) {
129
129
  fs.copyFileSync(path.join(ROOT, name), path.join(STAGE, name));
130
130
  }
131
131
  fs.cpSync(path.join(ROOT, 'src'), path.join(STAGE, 'src'), { recursive: true });
@@ -1,3 +1,4 @@
1
+ import { currentRevision } from '../storage/open.mjs';
1
2
  import { payloadHash } from '../identity.mjs';
2
3
  import {
3
4
  COMPRESSION_STEPS,
@@ -188,6 +189,7 @@ function compileRoundInner({
188
189
  project = null,
189
190
  sessionId = null,
190
191
  dream = null,
192
+ modules = null,
191
193
  }) {
192
194
  if (!tokenizer || H === null) {
193
195
  return Object.freeze({
@@ -206,11 +208,11 @@ function compileRoundInner({
206
208
  feedback: (item) => feedback(item.id),
207
209
  });
208
210
  const depsDream = dream;
209
- const dueItems = orderPendingDue(selectPendingDue({ main, mode, now: clock.now() }));
211
+ const dueItems = modules?.due === false ? [] : orderPendingDue(selectPendingDue({ main, mode, now: clock.now() }));
210
212
  const dueLines = dueItems.map((item) => dueDirectoryLine(item, 80));
211
213
  const dueTypedIds = dueItems.map((item) => item.typed_id);
212
214
  const dreamSurface =
213
- depsDream && dreamSurfaceEnabled(main)
215
+ modules?.dream !== false && depsDream && dreamSurfaceEnabled(main)
214
216
  ? surfaceDreams({ main, dream: depsDream, clock, mode, sessionId: sessionId ?? 'unknown-session', enabled: true, limit: 1 })
215
217
  : { enabled: false, items: [] };
216
218
  const dreamItems = dreamSurface.items ?? [];
@@ -248,7 +250,6 @@ function compileRoundInner({
248
250
  const typedIds = project && mode === 'work' ? [`project:${project.projectId}`] : [];
249
251
  const fixedText = buildFixedText({ clock, mode, project: mode === 'work' ? project : null });
250
252
  const snapshotId = `snap:${payloadHash({
251
- roundId,
252
253
  mode,
253
254
  revision: selected.revision,
254
255
  policyRevision,
@@ -443,7 +444,7 @@ function compileRoundInner({
443
444
  roundId,
444
445
  mode,
445
446
  level,
446
- sourceRevision: selected.revision,
447
+ sourceRevision: currentRevision(main),
447
448
  policyRevision,
448
449
  sectionText: chosen.sectionText,
449
450
  sectionHash,
@@ -469,7 +470,7 @@ function compileRoundInner({
469
470
  mode: 'auto',
470
471
  snapshot_revision: snapshotId,
471
472
  roundId,
472
- source_revision: selected.revision,
473
+ source_revision: currentRevision(main),
473
474
  policy_revision: policyRevision,
474
475
  delivery_level: level,
475
476
  delivery: 'unknown',
@@ -50,7 +50,7 @@ export function recordDelivery({
50
50
  missing = expected.filter((id) => !receivedSet.has(id));
51
51
  extra = received.filter((id) => !expectedSet.has(id));
52
52
  hashMatched = payloadHash !== null && manifest?.hashes?.payload === payloadHash;
53
- if (missing.length === 0 && extra.length === 0 && hashMatched) {
53
+ if (actualMemories.length > 0 && missing.length === 0 && extra.length === 0 && hashMatched) {
54
54
  delivery = 'confirmed';
55
55
  } else if (received.length > 0) {
56
56
  delivery = 'incomplete';
@@ -277,8 +277,16 @@ export function getGroupPage({
277
277
  if (decoded.s !== snapshotRevision || decoded.g !== groupId) {
278
278
  return { status: 'error', code: 'invalid_cursor', reason: 'cursor_binding_mismatch', clock: clock.at().at };
279
279
  }
280
- if (main && Number(snapshot.source_revision) !== Number(currentRevision(main))) {
281
- return staleCursor(control, clock);
280
+ if (main) {
281
+ const manifest = snapshot.manifest_json ? JSON.parse(snapshot.manifest_json) : {};
282
+ const expectedCount = Array.isArray(manifest.eligible_ids) ? manifest.eligible_ids.length : null;
283
+ const currentCount = Number(main.prepare('SELECT COUNT(*) AS n FROM memories').get().n);
284
+ if (
285
+ Number(snapshot.source_revision) !== Number(currentRevision(main)) ||
286
+ (expectedCount !== null && currentCount > expectedCount)
287
+ ) {
288
+ return staleCursor(control, clock);
289
+ }
282
290
  }
283
291
  offset = decoded.o;
284
292
  }
@@ -37,6 +37,19 @@ function dueDeadline(main, memoryId) {
37
37
  return queue?.deadline_at ?? null;
38
38
  }
39
39
 
40
+ /** Merge may not silently erase due semantics; anything due-bearing is refused. */
41
+ function assertMergeAdmissible(main, memory) {
42
+ if (['open', 'pending-due', 'pending-done', 'cooled-due', 'shelved-due', 'done', 'dropped'].includes(memory.due_state)) {
43
+ throw new WriteError('blocked_by_due_state', { memoryId: memory.id, dueState: memory.due_state });
44
+ }
45
+ if (memory.type === 'promise') {
46
+ throw new WriteError('blocked_by_due_state', { memoryId: memory.id, reason: 'promise' });
47
+ }
48
+ if (memory.due_at || dueDeadline(main, memory.id)) {
49
+ throw new WriteError('blocked_by_due_state', { memoryId: memory.id, reason: 'deadline' });
50
+ }
51
+ }
52
+
40
53
  /**
41
54
  * Mechanical exact merge only: no similarity, no NFKC, no negation stripping.
42
55
  * Endpoints must be active, resolved and accepted; dream-linked rows only yield a
@@ -58,9 +71,7 @@ export function mergeExact(deps, { runtime, ids }) {
58
71
  }
59
72
  }
60
73
  for (const memory of memories) {
61
- if (['open', 'pending-due', 'pending-done', 'cooled-due', 'shelved-due'].includes(memory.due_state)) {
62
- throw new WriteError('blocked_by_due_state', { memoryId: memory.id, dueState: memory.due_state });
63
- }
74
+ assertMergeAdmissible(main, memory);
64
75
  if (memory.key_status !== 'resolved') throw new WriteError('unresolved_forbidden', { memoryId: memory.id });
65
76
  if (memory.binding_status !== 'accepted') throw new WriteError('binding_not_accepted', { memoryId: memory.id });
66
77
  }
@@ -163,6 +174,10 @@ export function mergeExact(deps, { runtime, ids }) {
163
174
  };
164
175
  let returnStorage = null;
165
176
  withImmediate(main, () => {
177
+ for (const id of unique) {
178
+ const fresh = getMemory(main, id);
179
+ assertMergeAdmissible(main, fresh);
180
+ }
166
181
  insertObservation(main, {
167
182
  id: observationId,
168
183
  idempotency_key: `obs:merge:${mergeKey}`,
@@ -481,8 +481,27 @@ export function verifyPlan(deps, plan) {
481
481
  continue;
482
482
  }
483
483
  report.items += 1;
484
- if (item.itemKind === 'graph_node') report.graph_nodes += 1;
485
- if (item.itemKind === 'graph_edge') report.graph_edges += 1;
484
+ if (item.itemKind === 'graph_node') {
485
+ report.graph_nodes += 1;
486
+ const node = main.prepare('SELECT * FROM graph_nodes WHERE id = ?').get(item.payload.id);
487
+ if (!node) report.missing.push(`${item.itemId}:graph_node`);
488
+ else if (node.label !== item.payload.label || node.kind !== item.payload.kind || node.source_ref !== `node:${item.payload.id}`) {
489
+ report.missing.push(`${item.itemId}:graph_node_content`);
490
+ }
491
+ }
492
+ if (item.itemKind === 'graph_edge') {
493
+ report.graph_edges += 1;
494
+ const edge = main.prepare('SELECT * FROM graph_edges WHERE id = ?').get(item.payload.id);
495
+ if (!edge) report.missing.push(`${item.itemId}:graph_edge`);
496
+ else if (
497
+ edge.from_id !== item.payload.from ||
498
+ edge.to_id !== item.payload.to ||
499
+ edge.kind !== item.payload.kind ||
500
+ edge.source_ref !== `edge:${item.payload.id}`
501
+ ) {
502
+ report.missing.push(`${item.itemId}:graph_edge_content`);
503
+ }
504
+ }
486
505
  if (item.itemKind === 'memory') {
487
506
  const targets = JSON.parse(row.target_ids);
488
507
  const observation = targets[0] ? getObservation(main, targets[0]) : null;