dsh-memsys 0.1.0 → 0.2.0
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 +27 -6
- package/client.js +110 -0
- package/index.mjs +99 -10
- package/package.json +9 -2
- package/scripts/build.mjs +2 -1
- package/scripts/import-legacy.mjs +12 -2
- package/scripts/live-check.mjs +1 -1
- package/src/dream/organize.mjs +18 -3
- package/src/import/legacy.mjs +21 -2
- package/src/import/pointers.mjs +62 -20
- package/src/import/rollback.mjs +90 -37
- package/src/runtime/summary.mjs +197 -0
- package/test/fixtures/p3/c1-baseline.json +90 -0
- package/test/p3-c1-dream.test.mjs +1 -1
- package/test/p6-b1-merge.test.mjs +60 -0
- package/test/p6-b2-pointers.test.mjs +77 -0
- package/test/p6-l6-rollback.test.mjs +38 -0
- package/test/p6-ui-summary.test.mjs +309 -0
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
|
-
##
|
|
13
|
+
## 安装
|
|
14
14
|
|
|
15
|
-
|
|
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,24 @@ node scripts/live-check.mjs --case tools --profile memsys # 真机:插件
|
|
|
57
63
|
|
|
58
64
|
**设计原则**:事实只追加;当前状态是可重建投影;模型只能提交候选,不能直接落事实;一切写后读回按固化预期比对,不是"行还在就算成功"。
|
|
59
65
|
|
|
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.auto_inject` > 默认 `false`。
|
|
75
|
+
`/api/summary` 会返回 `auto_inject_source`,告诉你这个值是从哪来的(`ui-config` / `profile` / `default`)。
|
|
76
|
+
|
|
77
|
+
**headless 不受影响**:没有 `webServer` 时那段注册不激活,浏览器半也不加载。
|
|
78
|
+
|
|
60
79
|
## 状态
|
|
61
80
|
|
|
62
|
-
|
|
81
|
+
已实现并在真机验证**读**与**写**(`dispatch=ok` 返回真实命中、`record=ok` 写入候选与记忆);**界面**已落地并有 7 条测试,但"真机 GUI 里三个 slot 真的画出来"要装进 profile 目测才算完。
|
|
82
|
+
|
|
83
|
+
仍有 4 条设计条款未达标:**混版分页测试**(C3,代码已接 `stale_cursor`)、**空分组回执边界**(C1)、**证据原文分片续页**(C4)、以及 `L6` 的"逐项重放 relations 内容"(现在只核到内容 hash + 关联)。逐条见文末"已知边界",不粉饰。
|
|
63
84
|
|
|
64
85
|
MIT License。
|
|
65
86
|
|
package/client.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
function MemorySettings() {
|
|
53
|
+
const [state, setState] = React.useState(null);
|
|
54
|
+
const [note, setNote] = React.useState('');
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
fetch(API)
|
|
57
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(String(response.status)))))
|
|
58
|
+
.then(setState)
|
|
59
|
+
.catch(() => setState({ error: true }));
|
|
60
|
+
}, []);
|
|
61
|
+
const save = (value) => {
|
|
62
|
+
const previous = state?.auto_inject === true;
|
|
63
|
+
setState((current) => ({ ...(current ?? {}), auto_inject: value }));
|
|
64
|
+
setNote('保存中…');
|
|
65
|
+
fetch(CONFIG, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'content-type': 'application/json' },
|
|
68
|
+
body: JSON.stringify({ auto_inject: value }),
|
|
69
|
+
})
|
|
70
|
+
.then((response) => (response.ok ? setNote('已保存') : Promise.reject(new Error(String(response.status)))))
|
|
71
|
+
.catch(() => {
|
|
72
|
+
setState((current) => ({ ...(current ?? {}), auto_inject: previous }));
|
|
73
|
+
setNote('保存失败');
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
return h(
|
|
77
|
+
'div',
|
|
78
|
+
{ style: { padding: '8px 0', lineHeight: 1.7 } },
|
|
79
|
+
h('label', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
80
|
+
h('input', {
|
|
81
|
+
type: 'checkbox',
|
|
82
|
+
checked: state?.auto_inject === true,
|
|
83
|
+
disabled: !state || state.error === true,
|
|
84
|
+
onChange: (event) => save(event.target.checked),
|
|
85
|
+
}),
|
|
86
|
+
'自动注入记忆',
|
|
87
|
+
),
|
|
88
|
+
h('p', { style: { opacity: 0.75, margin: '6px 0 0' } }, `数据目录:${state?.data_dir ?? '未配置'}`),
|
|
89
|
+
h('p', { style: { opacity: 0.75, margin: '4px 0 0' } },
|
|
90
|
+
`当前生效值来源:${SOURCE_LABEL[state?.auto_inject_source] ?? state?.auto_inject_source ?? '未知'}`),
|
|
91
|
+
note ? h('p', { style: { opacity: 0.75, margin: '4px 0 0' } }, note) : null,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
inject: ['slots'],
|
|
97
|
+
apply(ctx) {
|
|
98
|
+
ctx.slots.inject('main', () =>
|
|
99
|
+
ctx.slots.register({ name: 'main', key: NS }, MemoryPanel),
|
|
100
|
+
);
|
|
101
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
102
|
+
ctx.slots.register({ name: 'sidebar.panellist', id: NS, order: 30, label: () => '记忆' }, MemoryIcon),
|
|
103
|
+
);
|
|
104
|
+
ctx.slots.inject('settings.section', () =>
|
|
105
|
+
ctx.slots.register({ name: 'settings.section', id: NS, order: 40, label: () => '记忆系统' }, MemorySettings),
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
});
|
package/index.mjs
CHANGED
|
@@ -12,13 +12,64 @@ 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, resolveAutoInject, writeUiConfig } from './src/runtime/summary.mjs';
|
|
15
16
|
|
|
16
17
|
export const name = 'dsh-memsys';
|
|
17
18
|
/** tools is a real host service; static inject is the verified registration path. */
|
|
18
19
|
export const inject = ['tools'];
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/**
|
|
22
|
+
* `auto_inject` has three ranks, highest first:
|
|
23
|
+
* 1. `<data_dir>/ui-config.json` — written by the settings switch;
|
|
24
|
+
* 2. the profile patch config (`config.auto_inject`);
|
|
25
|
+
* 3. `false`.
|
|
26
|
+
* The file wins so a toggle survives a restart without rewriting the profile,
|
|
27
|
+
* and the in-memory value is applied immediately (no restart).
|
|
28
|
+
*/
|
|
29
|
+
function createUiState(config = {}, logger = console) {
|
|
30
|
+
const dataDir = config.data_dir ?? process.env.MEMSYS_DATA_DIR ?? null;
|
|
31
|
+
let state = resolveAutoInject({ dataDir, profileValue: config.auto_inject, logger });
|
|
32
|
+
return {
|
|
33
|
+
dataDir,
|
|
34
|
+
get autoInject() {
|
|
35
|
+
return state.value;
|
|
36
|
+
},
|
|
37
|
+
get source() {
|
|
38
|
+
return state.source;
|
|
39
|
+
},
|
|
40
|
+
set autoInject(value) {
|
|
41
|
+
state = { value: value === true, source: 'ui-config' };
|
|
42
|
+
},
|
|
43
|
+
sync: () => {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createHostHandlers(config = {}, ctx = null, ui = null) {
|
|
21
48
|
const dataDir = config.data_dir ?? process.env.MEMSYS_DATA_DIR ?? null;
|
|
49
|
+
const uiState = ui ?? {
|
|
50
|
+
get autoInject() {
|
|
51
|
+
return config.auto_inject === true;
|
|
52
|
+
},
|
|
53
|
+
get source() {
|
|
54
|
+
return typeof config.auto_inject === 'boolean' ? 'profile' : 'default';
|
|
55
|
+
},
|
|
56
|
+
set autoInject(_value) {},
|
|
57
|
+
sync: () => {},
|
|
58
|
+
};
|
|
59
|
+
const uiSummary = createSummaryHandler({
|
|
60
|
+
main: () => db().main,
|
|
61
|
+
dataDir,
|
|
62
|
+
getAutoInject: () => ({ value: uiState.autoInject, source: uiState.source }),
|
|
63
|
+
onAutoInject: (next) => {
|
|
64
|
+
if (!writeUiConfig(dataDir, next)) {
|
|
65
|
+
const err = new Error('data_dir_missing');
|
|
66
|
+
err.code = 'data_dir_missing';
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
uiState.autoInject = next;
|
|
70
|
+
uiState.sync();
|
|
71
|
+
},
|
|
72
|
+
});
|
|
22
73
|
let handles = null;
|
|
23
74
|
const db = () => {
|
|
24
75
|
if (!dataDir) {
|
|
@@ -67,6 +118,7 @@ function createHostHandlers(config = {}, ctx = null) {
|
|
|
67
118
|
memsys_project_get: (args) => createProjectHandler({ main: db().main, clock: systemClock }).get(args),
|
|
68
119
|
memsys_self_get: (args) => createSelfHandler({ main: db().main, clock: systemClock }).get(args),
|
|
69
120
|
memsys_feed: (args) => createFeedHandler({ clock: systemClock }).feed(args),
|
|
121
|
+
memsys_ui_summary: (req, res) => uiSummary(req, res),
|
|
70
122
|
memsys_record: (args, exec) => {
|
|
71
123
|
const identity = runtimeFromExec({ exec, ctx, tool: 'record', args });
|
|
72
124
|
if (!identity.ok) return { status: 'error', code: identity.code, ...identity };
|
|
@@ -94,7 +146,8 @@ function createHostHandlers(config = {}, ctx = null) {
|
|
|
94
146
|
* section exists only for the isolated probe profile.
|
|
95
147
|
*/
|
|
96
148
|
export async function apply(ctx, config = {}) {
|
|
97
|
-
const
|
|
149
|
+
const ui = createUiState(config);
|
|
150
|
+
const handlers = createHostHandlers(config, ctx, ui);
|
|
98
151
|
const registration = await registerHostTools({ ctx, handlers });
|
|
99
152
|
const toolProbe = process.env.MEMSYS_TOOL_PROBE ?? null;
|
|
100
153
|
const writeToolProbe = (deferred) => {
|
|
@@ -235,19 +288,55 @@ export async function apply(ctx, config = {}) {
|
|
|
235
288
|
write();
|
|
236
289
|
}
|
|
237
290
|
|
|
238
|
-
|
|
291
|
+
if (typeof ctx?.inject === 'function') {
|
|
292
|
+
try {
|
|
293
|
+
ctx.inject(['webServer'], (web) => {
|
|
294
|
+
web.effect(
|
|
295
|
+
() =>
|
|
296
|
+
web.webServer.register({
|
|
297
|
+
kind: 'exact',
|
|
298
|
+
path: '/dsh-memsys/api/summary',
|
|
299
|
+
handler: (req, res) => handlers.memsys_ui_summary(req, res),
|
|
300
|
+
}),
|
|
301
|
+
'memsys: ui summary route',
|
|
302
|
+
);
|
|
303
|
+
web.effect(
|
|
304
|
+
() =>
|
|
305
|
+
web.webServer.register({
|
|
306
|
+
kind: 'exact',
|
|
307
|
+
path: '/dsh-memsys/api/config',
|
|
308
|
+
handler: (req, res) => handlers.memsys_ui_summary(req, res),
|
|
309
|
+
}),
|
|
310
|
+
'memsys: ui config route',
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
} catch {
|
|
314
|
+
// profiles without webServer stay headless
|
|
315
|
+
}
|
|
316
|
+
}
|
|
239
317
|
const diagnostic = config.diagnostic_section === true;
|
|
240
318
|
let disposeSection = () => {};
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
: '[memsys] diagnostic section(auto_inject=false)';
|
|
319
|
+
const syncSection = () => {
|
|
320
|
+
disposeSection();
|
|
321
|
+
disposeSection = () => {};
|
|
322
|
+
if (!ui.autoInject && !diagnostic) return;
|
|
246
323
|
disposeSection = registerSection(ctx, {
|
|
247
|
-
getText
|
|
324
|
+
getText: () =>
|
|
325
|
+
ui.autoInject
|
|
326
|
+
? '[memsys] 注入:tools-only(本批未取得宿主预算与回执,不作逐轮交付承诺)'
|
|
327
|
+
: '[memsys] diagnostic section(auto_inject=false)',
|
|
248
328
|
diagnosticMarker: process.env.MEMSYS_SECTION_PROBE ?? null,
|
|
249
329
|
});
|
|
250
|
-
}
|
|
330
|
+
};
|
|
331
|
+
// A profile without systemPrompt (headless/embedded) must still boot the tools.
|
|
332
|
+
ui.sync = () => {
|
|
333
|
+
try {
|
|
334
|
+
syncSection();
|
|
335
|
+
} catch {
|
|
336
|
+
// keep the host half alive; the section is optional
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
ui.sync();
|
|
251
340
|
return () => {
|
|
252
341
|
disposeSection();
|
|
253
342
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-memsys",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
}
|
package/scripts/live-check.mjs
CHANGED
|
@@ -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 });
|
package/src/dream/organize.mjs
CHANGED
|
@@ -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
|
-
|
|
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}`,
|
package/src/import/legacy.mjs
CHANGED
|
@@ -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')
|
|
485
|
-
|
|
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;
|
package/src/import/pointers.mjs
CHANGED
|
@@ -5,6 +5,32 @@ import { withImmediate } from '../storage/open.mjs';
|
|
|
5
5
|
import { appendRelation } from '../storage/repositories.mjs';
|
|
6
6
|
import { WriteError } from '../write/authority.mjs';
|
|
7
7
|
|
|
8
|
+
export function verifyPointerSource({ row, match, observationText }) {
|
|
9
|
+
if (!match?.file || !match?.span) return { ok: false, reason: 'no_file_version_evidence' };
|
|
10
|
+
const start = Number(match.span.start);
|
|
11
|
+
const end = Number(match.span.end);
|
|
12
|
+
let bytes;
|
|
13
|
+
try {
|
|
14
|
+
bytes = fs.readFileSync(match.file);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return { ok: false, reason: 'file_unreadable', error: err.message };
|
|
17
|
+
}
|
|
18
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > bytes.length || start >= end) {
|
|
19
|
+
return { ok: false, reason: 'span_out_of_range' };
|
|
20
|
+
}
|
|
21
|
+
const slice = bytes.slice(start, end);
|
|
22
|
+
const spanHash = crypto.createHash('sha256').update(slice).digest('hex');
|
|
23
|
+
if (spanHash !== match.spanHash) return { ok: false, reason: 'span_hash_mismatch' };
|
|
24
|
+
const fileHash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
25
|
+
if (match.fileHash && fileHash !== match.fileHash) return { ok: false, reason: 'file_hash_mismatch' };
|
|
26
|
+
if (match.location !== `${match.file}#bytes:${start}-${end}`) return { ok: false, reason: 'location_not_byte_binding' };
|
|
27
|
+
if (typeof observationText !== 'string' || slice.toString('utf8') !== observationText) {
|
|
28
|
+
return { ok: false, reason: 'observation_text_mismatch' };
|
|
29
|
+
}
|
|
30
|
+
if (match.versionHash !== row.source_version_hash) return { ok: false, reason: 'version_hash_mismatch' };
|
|
31
|
+
return { ok: true, spanHash, fileHash, observationText };
|
|
32
|
+
}
|
|
33
|
+
|
|
8
34
|
/** Match the imported source version, never "the latest path with this name". */
|
|
9
35
|
export function planPointers({ main, clock }, versionsFile) {
|
|
10
36
|
const parsed = JSON.parse(fs.readFileSync(versionsFile, 'utf8'));
|
|
@@ -35,28 +61,14 @@ export function planPointers({ main, clock }, versionsFile) {
|
|
|
35
61
|
skipped.push({ sourceObjectId: row.source_object_id, reason: 'no_version_match' });
|
|
36
62
|
continue;
|
|
37
63
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const bytes = fs.readFileSync(match.file);
|
|
45
|
-
const start = Number(match.span.start);
|
|
46
|
-
const end = Number(match.span.end);
|
|
47
|
-
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > bytes.length || start >= end) {
|
|
48
|
-
skipped.push({ sourceObjectId: row.source_object_id, reason: 'span_out_of_range' });
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
spanHash = crypto.createHash('sha256').update(bytes.slice(start, end)).digest('hex');
|
|
52
|
-
} catch (err) {
|
|
53
|
-
skipped.push({ sourceObjectId: row.source_object_id, reason: 'file_unreadable', error: err.message });
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (spanHash !== match.spanHash) {
|
|
57
|
-
skipped.push({ sourceObjectId: row.source_object_id, reason: 'span_hash_mismatch' });
|
|
64
|
+
const observationId = targets[0];
|
|
65
|
+
const observation = observationId ? main.prepare('SELECT * FROM observations WHERE id = ?').get(observationId) : null;
|
|
66
|
+
const observed = verifyPointerSource({ row, match, observationText: observation?.text });
|
|
67
|
+
if (!observed.ok) {
|
|
68
|
+
skipped.push({ sourceObjectId: row.source_object_id, reason: observed.reason, error: observed.error });
|
|
58
69
|
continue;
|
|
59
70
|
}
|
|
71
|
+
const spanHash = observed.spanHash;
|
|
60
72
|
const pointer = main.prepare('SELECT * FROM pointers WHERE memory_id = ?').get(memoryId);
|
|
61
73
|
if (pointer?.status === 'exact' && pointer.location === match.location) {
|
|
62
74
|
skipped.push({ sourceObjectId: row.source_object_id, reason: 'already_exact' });
|
|
@@ -73,6 +85,8 @@ export function planPointers({ main, clock }, versionsFile) {
|
|
|
73
85
|
file: match.file,
|
|
74
86
|
span: match.span,
|
|
75
87
|
spanHash,
|
|
88
|
+
sourceFileHash: observed.fileHash,
|
|
89
|
+
observationHash: payloadHash(observed.observationText),
|
|
76
90
|
revision,
|
|
77
91
|
};
|
|
78
92
|
items.push({
|
|
@@ -91,8 +105,36 @@ export function planPointers({ main, clock }, versionsFile) {
|
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
export function applyPointers({ main, clock }, plan) {
|
|
108
|
+
const recomputed = payloadHash({ policy: 'legacy-v1', items: plan.items, skipped: plan.skipped });
|
|
109
|
+
if (recomputed !== plan.planHash) throw new WriteError('plan_hash_mismatch');
|
|
94
110
|
const applied = [];
|
|
95
111
|
for (const item of plan.items) {
|
|
112
|
+
let bytes;
|
|
113
|
+
try {
|
|
114
|
+
bytes = fs.readFileSync(item.payload.file);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw new WriteError('source_unreadable', { memoryId: item.memoryId, error: err.message });
|
|
117
|
+
}
|
|
118
|
+
const start = Number(item.payload.span.start);
|
|
119
|
+
const end = Number(item.payload.span.end);
|
|
120
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > bytes.length || start >= end) {
|
|
121
|
+
throw new WriteError('source_span_changed', { memoryId: item.memoryId });
|
|
122
|
+
}
|
|
123
|
+
const spanHash = crypto.createHash('sha256').update(bytes.slice(start, end)).digest('hex');
|
|
124
|
+
const fileHash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
125
|
+
const evidence = main
|
|
126
|
+
.prepare('SELECT observation_id FROM evidence WHERE memory_id = ? ORDER BY id LIMIT 1')
|
|
127
|
+
.get(item.memoryId);
|
|
128
|
+
const observation = evidence?.observation_id
|
|
129
|
+
? main.prepare('SELECT text FROM observations WHERE id = ?').get(evidence.observation_id)
|
|
130
|
+
: null;
|
|
131
|
+
if (
|
|
132
|
+
spanHash !== item.payload.spanHash ||
|
|
133
|
+
fileHash !== item.payload.sourceFileHash ||
|
|
134
|
+
payloadHash(observation?.text ?? '') !== item.payload.observationHash
|
|
135
|
+
) {
|
|
136
|
+
throw new WriteError('source_bytes_changed', { memoryId: item.memoryId });
|
|
137
|
+
}
|
|
96
138
|
const exists = main
|
|
97
139
|
.prepare(
|
|
98
140
|
"SELECT payload FROM relations WHERE type = 'pointer-change' AND json_extract(payload, '$.memoryId') = ? AND json_extract(payload, '$.basis') = 'pointer-backfill'",
|