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 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,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
- 已实现并在真机验证**读**与**写**;仍有 6 条设计条款未达标(合并时 due 语义、回退证明深度、门牌与骨架核验、混版分页测试等)。逐条列在本文件末尾"已知边界"一节,不粉饰。
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
- function createHostHandlers(config = {}, ctx = null) {
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 handlers = createHostHandlers(config, ctx);
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
- const autoInject = config.auto_inject === true;
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
- if (autoInject || diagnostic) {
242
- const getText = () =>
243
- autoInject
244
- ? '[memsys] 注入:tools-only(本批未取得宿主预算与回执,不作逐轮交付承诺)'
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.1.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
  }
@@ -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 });
@@ -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;
@@ -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
- if (!match.file || !match.span || !match.spanHash) {
39
- skipped.push({ sourceObjectId: row.source_object_id, reason: 'no_file_version_evidence' });
40
- continue;
41
- }
42
- let spanHash = null;
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'",