@max-null/dsh-memory 0.7.0 → 0.7.2

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
@@ -55,6 +55,16 @@ npm install @max-null/dsh-memory
55
55
 
56
56
  两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
57
57
 
58
+ ## 多实例共存(0.7.1)
59
+
60
+ 两个 DSH 实例(例如 DSH web 与 SSiD 桌面壳)可以同时运行、共用同一个 `DSH_HOME`,**记忆不再互相抹掉**。
61
+
62
+ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后端都声明不做跨进程协调(`storage-json`:*writer per process and last-write-wins is correct*;`storage-sqlite`:*cross-process coordination is out of scope*)。插件层的处置是**写前重读**(等价于 update 前先 select):每次写入在串行区里先比对存储文件指纹(mtime + size),发现磁盘被别的实例动过就先重载再写。代价是磁盘没变时的一次 `statSync`。
63
+
64
+ 残余窗口只剩两个实例在**同一瞬间**写——此时后写者赢;同时写**不同**记忆已不再互相影响。
65
+
66
+ **未覆盖**:`workspace.json`(工作区登记)由 DSH 自己的 workspace 服务写,插件层够不着,仍会被双实例互相覆盖;根因处置在上游——[discussion #6882](https://github.com/deepseek-ai/deepseek-harness/discussions/6882)。当前防线是轮转快照备份(SSiD 侧 `shell/scripts/backup-storages.mjs` + 计划任务)。
67
+
58
68
  ## 使用流程(写入即生效,人为例外干预)
59
69
 
60
70
  提示词模板库(0.6.0):`prompt_search / prompt_get / prompt_list / prompt_add` 四个工具管理**模板库**——
@@ -102,6 +112,8 @@ memory_forget → 随时删除(删除始终是人的动作)
102
112
 
103
113
  ## SSID 系列
104
114
 
115
+ 本插件是 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 全家桶的一员;也可以单独安装到任意 DSH profile——除设置页的记忆面板外不依赖其它同系列插件。
116
+
105
117
  ## 开发
106
118
 
107
119
  ```bash
package/dist/engine.d.ts CHANGED
@@ -222,6 +222,10 @@ export declare class MemoryEngine extends Service {
222
222
  /** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
223
223
  private registeredProjectBackends;
224
224
  private facility?;
225
+ /** 各存储文件上次被我们确认过的指纹;用于识别「另一个实例写过」。 */
226
+ private readonly storeStamps;
227
+ /** 写入串行链:「刷新—读—改—写」必须整体互斥,否则并发写会在其间穿插。 */
228
+ private writeChain;
225
229
  /** 会话启动维护只跑一次的标记(2026-09-15)。 */
226
230
  private maintenanceDone;
227
231
  constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
@@ -240,7 +244,13 @@ export declare class MemoryEngine extends Service {
240
244
  /**
241
245
  * 强制重载存储:关闭全局域与全部已开工作区域后重开(unit 释放后
242
246
  * backend 重读文件),放弃内存缓存。供外部编辑记忆文件后刷新
243
- * (JsonStorageBackend 打开时加载一次,无 watch——2026-08-19 实测)。
247
+ * (JsonStorageBackend 打开时加载一次,无 watch——2026-08-19 实测);
248
+ * 多实例写入协调(0.7.1)让它在每次「磁盘被别人改过」时自动触发。
249
+ *
250
+ * **已打开的工作区要重开,而不是一关了之**:注入 provider 是同步回调、只读
251
+ * 已打开的表,关掉就等同于「这个工作区没有常驻记忆」——工作区记忆会从上下文里
252
+ * 静默消失,直到某次工具调用把它重新打开。重开的表同样从磁盘重读,
253
+ * 「放弃内存缓存」这条语义不变,改变的只是「重读完别忘了装回去」。
244
254
  */
245
255
  reload(): Promise<void>;
246
256
  /**
@@ -248,7 +258,30 @@ export declare class MemoryEngine extends Service {
248
258
  * 命中危险内容规则则隔离:`status` 留 `suggested`(天然不注入)**并且**置
249
259
  * `quarantined`(不进检索),双保险,等待人工放行或删除。
250
260
  */
261
+ /** 文件指纹:mtime + size(size 兜住同一毫秒内的两次写)。 */
262
+ private static fileStamp;
263
+ /** 全局记忆的存储文件(JsonStorageBackend 的 unit 文件名 = domain 名)。 */
264
+ private globalStoreFile;
265
+ /** 某工作区记忆的存储文件。 */
266
+ private projectStoreFile;
267
+ /** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
268
+ private noteStoreStamps;
269
+ /**
270
+ * 写前新鲜度门:存储文件被外部改过就重载,避免整份覆盖吃掉对方的写入。
271
+ * 必须在取表**之前**调用——{@link reload} 重开 facility,此前取到的 table 引用会失效。
272
+ */
273
+ private refreshForWrite;
274
+ /**
275
+ * 一次写入的完整作用域:串行化 → 新鲜度门 → 执行 → 记指纹。
276
+ * 刷新与「读—改—写」必须同处一个互斥区,否则并发写会在两者之间穿插。
277
+ * @param projectCwd - 本次写入涉及的工作区;无则只覆盖全局存储。
278
+ * @param run - 实际的读改写;必须在内部重新取表(刷新后旧引用失效)。
279
+ * @returns `run` 的返回值。
280
+ */
281
+ private writeScope;
251
282
  remember(input: MemoryWrite, projectCwd?: string): Promise<MemoryRecord>;
283
+ /** {@link remember} 的实现体;必须经 `writeScope` 进入(多实例写入协调)。 */
284
+ private rememberInner;
252
285
  list(filter?: MemoryFilter, projectCwd?: string): Promise<MemoryRecord[]>;
253
286
  /**
254
287
  * 关键词检索。每次成功返回的检索都记一条查询日志(⑥ 2026-09-15)——命中与否都记:
@@ -283,6 +316,8 @@ export declare class MemoryEngine extends Service {
283
316
  private tableOf;
284
317
  /** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
285
318
  private markUsed;
319
+ /** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
320
+ private markUsedInner;
286
321
  /**
287
322
  * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
288
323
  * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
@@ -331,6 +366,8 @@ export declare class MemoryEngine extends Service {
331
366
  * @returns 被撤下的条数(诊断与日志用)。
332
367
  */
333
368
  demoteStale(projectCwd?: string, now?: number): Promise<number>;
369
+ /** {@link demoteStale} 的实现体;必须经 `writeScope` 进入。 */
370
+ private demoteStaleInner;
334
371
  /**
335
372
  * 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
336
373
  *
@@ -343,6 +380,8 @@ export declare class MemoryEngine extends Service {
343
380
  * @returns 本次状态发生变化的条数。
344
381
  */
345
382
  verifyAnchors(probes: AnchorProbes, projectCwd?: string): Promise<number>;
383
+ /** {@link verifyAnchors} 的实现体;必须经 `writeScope` 进入。 */
384
+ private verifyAnchorsInner;
346
385
  /**
347
386
  * 旧数据迁移(2026-09-15 静默记忆机制):一次性、幂等,会话启动维护时调用。
348
387
  *
@@ -363,13 +402,21 @@ export declare class MemoryEngine extends Service {
363
402
  quarantined: number;
364
403
  counted: number;
365
404
  }>;
405
+ /** {@link migrateLegacy} 的实现体;必须经 `writeScope` 进入。 */
406
+ private migrateLegacyInner;
366
407
  forget(id: MemoryId, projectCwd?: string): Promise<boolean>;
408
+ /** {@link forget} 的实现体;必须经 `writeScope` 进入。 */
409
+ private forgetInner;
367
410
  setStatus(id: MemoryId, status: MemoryStatus, projectCwd?: string): Promise<MemoryRecord>;
411
+ /** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
412
+ private setStatusInner;
368
413
  /**
369
414
  * 注入维度开关(0.3.0):只改 injected,不动审核状态。供 UI 面板
370
415
  * 「常驻注入」开关调用(remote.setInjected)。
371
416
  */
372
417
  setInjected(id: MemoryId, injected: boolean, projectCwd?: string): Promise<MemoryRecord>;
418
+ /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
419
+ private setInjectedInner;
373
420
  /**
374
421
  * 修改一条记忆的内容/关键词(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
375
422
  * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态;
@@ -379,6 +426,8 @@ export declare class MemoryEngine extends Service {
379
426
  content?: string;
380
427
  keywords?: string[];
381
428
  }, projectCwd?: string): Promise<MemoryRecord>;
429
+ /** {@link update} 的实现体;必须经 `writeScope` 进入。 */
430
+ private updateInner;
382
431
  /**
383
432
  * 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
384
433
  * `memory:recall` provider 是同步回调——只读已打开的表;预热让
package/dist/engine.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * is always created `suggested` and becomes effective only through `setStatus`.
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { Service } from '@deepseek-ai/cordis';
@@ -170,6 +170,10 @@ export class MemoryEngine extends Service {
170
170
  /** backend 只注册一次(registry 重名抛 duplicate;reload 清缓存后不得重复注册)。 */
171
171
  registeredProjectBackends = new Set();
172
172
  facility;
173
+ /** 各存储文件上次被我们确认过的指纹;用于识别「另一个实例写过」。 */
174
+ storeStamps = new Map();
175
+ /** 写入串行链:「刷新—读—改—写」必须整体互斥,否则并发写会在其间穿插。 */
176
+ writeChain = Promise.resolve();
173
177
  /** 会话启动维护只跑一次的标记(2026-09-15)。 */
174
178
  maintenanceDone = false;
175
179
  constructor(ctx, config = {}) {
@@ -258,22 +262,114 @@ export class MemoryEngine extends Service {
258
262
  /**
259
263
  * 强制重载存储:关闭全局域与全部已开工作区域后重开(unit 释放后
260
264
  * backend 重读文件),放弃内存缓存。供外部编辑记忆文件后刷新
261
- * (JsonStorageBackend 打开时加载一次,无 watch——2026-08-19 实测)。
265
+ * (JsonStorageBackend 打开时加载一次,无 watch——2026-08-19 实测);
266
+ * 多实例写入协调(0.7.1)让它在每次「磁盘被别人改过」时自动触发。
267
+ *
268
+ * **已打开的工作区要重开,而不是一关了之**:注入 provider 是同步回调、只读
269
+ * 已打开的表,关掉就等同于「这个工作区没有常驻记忆」——工作区记忆会从上下文里
270
+ * 静默消失,直到某次工具调用把它重新打开。重开的表同样从磁盘重读,
271
+ * 「放弃内存缓存」这条语义不变,改变的只是「重读完别忘了装回去」。
262
272
  */
263
273
  async reload() {
274
+ const opened = [...this.projectTables.keys()];
264
275
  await this.facility?.closeAll();
265
276
  for (const facility of this.projectFacilities.values())
266
277
  await facility.closeAll();
267
278
  this.projectTables.clear();
268
279
  this.projectFacilities.clear();
269
280
  await this.openGlobalFacility();
281
+ for (const key of opened) {
282
+ // 重开失败 = 该工作区退回「未打开」(本轮注入缺这部分,下次访问重试),
283
+ // 不该让触发重载的那一整次写入跟着失败。
284
+ await this.projectTableFor(key).catch(() => undefined);
285
+ }
270
286
  }
271
287
  /**
272
288
  * Create one record. 低危内容直接 `approved`(静默生效,2026-09-15 静默记忆机制);
273
289
  * 命中危险内容规则则隔离:`status` 留 `suggested`(天然不注入)**并且**置
274
290
  * `quarantined`(不进检索),双保险,等待人工放行或删除。
275
291
  */
292
+ // ── 多实例写入协调(2026-09-17)────────────────────────────────────────
293
+ //
294
+ // 另一个 DSH 实例(web 与 SSiD 同时运行)共用同一个 DSH_HOME 时,它写的记忆只落在
295
+ // 磁盘上;我们的内存态若比磁盘旧,下一次整份覆盖就会把对方的新记忆抹掉。DSH 的
296
+ // 存储层明确不做跨进程协调(storage-json「writer per process and last-write-wins
297
+ // is correct」、storage-sqlite「cross-process coordination is out of scope」),
298
+ // 所以协调放在这里:开写前比对存储文件指纹,变过就重载——等价于「update 前先
299
+ // select」。残余窗口只剩两个实例真正同时写,而记忆写入是低频操作。
300
+ /** 文件指纹:mtime + size(size 兜住同一毫秒内的两次写)。 */
301
+ static fileStamp(file) {
302
+ try {
303
+ const stat = statSync(file);
304
+ return `${stat.mtimeMs}:${stat.size}`;
305
+ }
306
+ catch {
307
+ return 'missing';
308
+ }
309
+ }
310
+ /** 全局记忆的存储文件(JsonStorageBackend 的 unit 文件名 = domain 名)。 */
311
+ globalStoreFile() {
312
+ return join(this.config.globalRoot ?? globalRoot(), 'memory.json');
313
+ }
314
+ /** 某工作区记忆的存储文件。 */
315
+ projectStoreFile(projectCwd) {
316
+ const key = join(projectCwd);
317
+ return join(this.projectRootFor(key), `${this.projectBackendName(key)}.json`);
318
+ }
319
+ /** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
320
+ noteStoreStamps(projectCwd) {
321
+ const global = this.globalStoreFile();
322
+ this.storeStamps.set(global, MemoryEngine.fileStamp(global));
323
+ const scopes = new Set(this.projectTables.keys());
324
+ if (projectCwd !== undefined && projectCwd !== '')
325
+ scopes.add(join(projectCwd));
326
+ for (const key of scopes) {
327
+ const file = this.projectStoreFile(key);
328
+ this.storeStamps.set(file, MemoryEngine.fileStamp(file));
329
+ }
330
+ }
331
+ /**
332
+ * 写前新鲜度门:存储文件被外部改过就重载,避免整份覆盖吃掉对方的写入。
333
+ * 必须在取表**之前**调用——{@link reload} 重开 facility,此前取到的 table 引用会失效。
334
+ */
335
+ async refreshForWrite(projectCwd) {
336
+ if (this.storeStamps.size === 0) {
337
+ this.noteStoreStamps(projectCwd);
338
+ return;
339
+ }
340
+ const files = [this.globalStoreFile()];
341
+ if (projectCwd !== undefined && projectCwd !== '')
342
+ files.push(this.projectStoreFile(projectCwd));
343
+ for (const file of files) {
344
+ if (MemoryEngine.fileStamp(file) === this.storeStamps.get(file))
345
+ continue;
346
+ await this.reload();
347
+ this.noteStoreStamps(projectCwd);
348
+ return;
349
+ }
350
+ }
351
+ /**
352
+ * 一次写入的完整作用域:串行化 → 新鲜度门 → 执行 → 记指纹。
353
+ * 刷新与「读—改—写」必须同处一个互斥区,否则并发写会在两者之间穿插。
354
+ * @param projectCwd - 本次写入涉及的工作区;无则只覆盖全局存储。
355
+ * @param run - 实际的读改写;必须在内部重新取表(刷新后旧引用失效)。
356
+ * @returns `run` 的返回值。
357
+ */
358
+ writeScope(projectCwd, run) {
359
+ const task = this.writeChain.then(async () => {
360
+ await this.refreshForWrite(projectCwd);
361
+ const result = await run();
362
+ this.noteStoreStamps(projectCwd);
363
+ return result;
364
+ });
365
+ this.writeChain = task.then(() => undefined, () => undefined);
366
+ return task;
367
+ }
276
368
  async remember(input, projectCwd) {
369
+ return this.writeScope(projectCwd, () => this.rememberInner(input, projectCwd));
370
+ }
371
+ /** {@link remember} 的实现体;必须经 `writeScope` 进入(多实例写入协调)。 */
372
+ async rememberInner(input, projectCwd) {
277
373
  const namespace = input.namespace ?? 'global';
278
374
  const table = namespace === 'project'
279
375
  ? await this.projectTableFor(projectCwd)
@@ -479,13 +575,17 @@ export class MemoryEngine extends Service {
479
575
  }
480
576
  /** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
481
577
  async markUsed(ids, projectCwd) {
578
+ return this.writeScope(projectCwd, () => this.markUsedInner(ids, projectCwd));
579
+ }
580
+ /** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
581
+ async markUsedInner(ids, projectCwd) {
482
582
  if (ids.length === 0)
483
583
  return;
484
584
  const now = Date.now();
485
585
  const global = this.requireTable('global');
486
- const project = projectCwd === undefined || projectCwd === ''
487
- ? undefined
488
- : this.projectTables.get(join(projectCwd));
586
+ // 取表走异步版:`writeScope` 可能在本次进入前刚 `reload()`(外部实例改过存储),
587
+ // 那时同步缓存已被清空——继续读缓存会把 project 侧的命中静默漏记。
588
+ const project = await this.projectTableFor(projectCwd);
489
589
  for (const id of ids) {
490
590
  const globalBlock = global.get(id);
491
591
  if (globalBlock !== undefined) {
@@ -625,6 +725,10 @@ export class MemoryEngine extends Service {
625
725
  * @returns 被撤下的条数(诊断与日志用)。
626
726
  */
627
727
  async demoteStale(projectCwd, now = Date.now()) {
728
+ return this.writeScope(projectCwd, () => this.demoteStaleInner(projectCwd, now));
729
+ }
730
+ /** {@link demoteStale} 的实现体;必须经 `writeScope` 进入。 */
731
+ async demoteStaleInner(projectCwd, now = Date.now()) {
628
732
  const threshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
629
733
  const tables = [this.requireTable('global')];
630
734
  if (projectCwd !== undefined && projectCwd !== '') {
@@ -664,6 +768,10 @@ export class MemoryEngine extends Service {
664
768
  * @returns 本次状态发生变化的条数。
665
769
  */
666
770
  async verifyAnchors(probes, projectCwd) {
771
+ return this.writeScope(projectCwd, () => this.verifyAnchorsInner(probes, projectCwd));
772
+ }
773
+ /** {@link verifyAnchors} 的实现体;必须经 `writeScope` 进入。 */
774
+ async verifyAnchorsInner(probes, projectCwd) {
667
775
  const tables = [this.requireTable('global')];
668
776
  if (projectCwd !== undefined && projectCwd !== '') {
669
777
  const project = await this.projectTableFor(projectCwd);
@@ -713,6 +821,10 @@ export class MemoryEngine extends Service {
713
821
  * @returns 迁移统计(放行 / 隔离 / 回填计数)。
714
822
  */
715
823
  async migrateLegacy(projectCwd) {
824
+ return this.writeScope(projectCwd, () => this.migrateLegacyInner(projectCwd));
825
+ }
826
+ /** {@link migrateLegacy} 的实现体;必须经 `writeScope` 进入。 */
827
+ async migrateLegacyInner(projectCwd) {
716
828
  const tables = [this.requireTable('global')];
717
829
  if (projectCwd !== undefined && projectCwd !== '') {
718
830
  const project = await this.projectTableFor(projectCwd);
@@ -778,6 +890,10 @@ export class MemoryEngine extends Service {
778
890
  return stats;
779
891
  }
780
892
  async forget(id, projectCwd) {
893
+ return this.writeScope(projectCwd, () => this.forgetInner(id, projectCwd));
894
+ }
895
+ /** {@link forget} 的实现体;必须经 `writeScope` 进入。 */
896
+ async forgetInner(id, projectCwd) {
781
897
  if (await this.requireTable('global').delete(id)) {
782
898
  this.ctx.emit('memory/changed', { operation: 'forgotten', id });
783
899
  return true;
@@ -790,6 +906,10 @@ export class MemoryEngine extends Service {
790
906
  return false;
791
907
  }
792
908
  async setStatus(id, status, projectCwd) {
909
+ return this.writeScope(projectCwd, () => this.setStatusInner(id, status, projectCwd));
910
+ }
911
+ /** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
912
+ async setStatusInner(id, status, projectCwd) {
793
913
  const global = this.requireTable('global').get(id);
794
914
  if (global !== undefined) {
795
915
  const updated = {
@@ -830,6 +950,10 @@ export class MemoryEngine extends Service {
830
950
  * 「常驻注入」开关调用(remote.setInjected)。
831
951
  */
832
952
  async setInjected(id, injected, projectCwd) {
953
+ return this.writeScope(projectCwd, () => this.setInjectedInner(id, injected, projectCwd));
954
+ }
955
+ /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
956
+ async setInjectedInner(id, injected, projectCwd) {
833
957
  const locate = async () => {
834
958
  const global = this.requireTable('global').get(id);
835
959
  if (global !== undefined)
@@ -872,6 +996,10 @@ export class MemoryEngine extends Service {
872
996
  * `injected` 保留原值。若新内容命中危险规则则转为隔离(与写入路径同一判定)。
873
997
  */
874
998
  async update(id, patch, projectCwd) {
999
+ return this.writeScope(projectCwd, () => this.updateInner(id, patch, projectCwd));
1000
+ }
1001
+ /** {@link update} 的实现体;必须经 `writeScope` 进入。 */
1002
+ async updateInner(id, patch, projectCwd) {
875
1003
  const applyPatch = (block) => {
876
1004
  const normalized = normalizeBlock(block);
877
1005
  const content = patch.content ?? block.content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -22,7 +22,9 @@
22
22
  "client.js",
23
23
  "cordis.patch.yml",
24
24
  "README.md",
25
- "LICENSE", "docs/shots"],
25
+ "LICENSE",
26
+ "docs/shots"
27
+ ],
26
28
  "dsh": {
27
29
  "bundle": {
28
30
  "patch": "./cordis.patch.yml"