@max-null/dsh-memory 0.7.0 → 0.7.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
@@ -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);
@@ -248,7 +252,30 @@ export declare class MemoryEngine extends Service {
248
252
  * 命中危险内容规则则隔离:`status` 留 `suggested`(天然不注入)**并且**置
249
253
  * `quarantined`(不进检索),双保险,等待人工放行或删除。
250
254
  */
255
+ /** 文件指纹:mtime + size(size 兜住同一毫秒内的两次写)。 */
256
+ private static fileStamp;
257
+ /** 全局记忆的存储文件(JsonStorageBackend 的 unit 文件名 = domain 名)。 */
258
+ private globalStoreFile;
259
+ /** 某工作区记忆的存储文件。 */
260
+ private projectStoreFile;
261
+ /** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
262
+ private noteStoreStamps;
263
+ /**
264
+ * 写前新鲜度门:存储文件被外部改过就重载,避免整份覆盖吃掉对方的写入。
265
+ * 必须在取表**之前**调用——{@link reload} 重开 facility,此前取到的 table 引用会失效。
266
+ */
267
+ private refreshForWrite;
268
+ /**
269
+ * 一次写入的完整作用域:串行化 → 新鲜度门 → 执行 → 记指纹。
270
+ * 刷新与「读—改—写」必须同处一个互斥区,否则并发写会在两者之间穿插。
271
+ * @param projectCwd - 本次写入涉及的工作区;无则只覆盖全局存储。
272
+ * @param run - 实际的读改写;必须在内部重新取表(刷新后旧引用失效)。
273
+ * @returns `run` 的返回值。
274
+ */
275
+ private writeScope;
251
276
  remember(input: MemoryWrite, projectCwd?: string): Promise<MemoryRecord>;
277
+ /** {@link remember} 的实现体;必须经 `writeScope` 进入(多实例写入协调)。 */
278
+ private rememberInner;
252
279
  list(filter?: MemoryFilter, projectCwd?: string): Promise<MemoryRecord[]>;
253
280
  /**
254
281
  * 关键词检索。每次成功返回的检索都记一条查询日志(⑥ 2026-09-15)——命中与否都记:
@@ -283,6 +310,8 @@ export declare class MemoryEngine extends Service {
283
310
  private tableOf;
284
311
  /** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
285
312
  private markUsed;
313
+ /** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
314
+ private markUsedInner;
286
315
  /**
287
316
  * 命中记账 + 自动升级(2026-09-15 静默记忆机制):命中次数达阈值即自动打开常驻
288
317
  * 注入——「被反复检索命中」是它值得每轮付费的唯一客观证据。统计性字段不 emit,
@@ -331,6 +360,8 @@ export declare class MemoryEngine extends Service {
331
360
  * @returns 被撤下的条数(诊断与日志用)。
332
361
  */
333
362
  demoteStale(projectCwd?: string, now?: number): Promise<number>;
363
+ /** {@link demoteStale} 的实现体;必须经 `writeScope` 进入。 */
364
+ private demoteStaleInner;
334
365
  /**
335
366
  * 锚点批量校验(2026-09-15):会话启动时调一次,把失效的条目标成 `stale`。
336
367
  *
@@ -343,6 +374,8 @@ export declare class MemoryEngine extends Service {
343
374
  * @returns 本次状态发生变化的条数。
344
375
  */
345
376
  verifyAnchors(probes: AnchorProbes, projectCwd?: string): Promise<number>;
377
+ /** {@link verifyAnchors} 的实现体;必须经 `writeScope` 进入。 */
378
+ private verifyAnchorsInner;
346
379
  /**
347
380
  * 旧数据迁移(2026-09-15 静默记忆机制):一次性、幂等,会话启动维护时调用。
348
381
  *
@@ -363,13 +396,21 @@ export declare class MemoryEngine extends Service {
363
396
  quarantined: number;
364
397
  counted: number;
365
398
  }>;
399
+ /** {@link migrateLegacy} 的实现体;必须经 `writeScope` 进入。 */
400
+ private migrateLegacyInner;
366
401
  forget(id: MemoryId, projectCwd?: string): Promise<boolean>;
402
+ /** {@link forget} 的实现体;必须经 `writeScope` 进入。 */
403
+ private forgetInner;
367
404
  setStatus(id: MemoryId, status: MemoryStatus, projectCwd?: string): Promise<MemoryRecord>;
405
+ /** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
406
+ private setStatusInner;
368
407
  /**
369
408
  * 注入维度开关(0.3.0):只改 injected,不动审核状态。供 UI 面板
370
409
  * 「常驻注入」开关调用(remote.setInjected)。
371
410
  */
372
411
  setInjected(id: MemoryId, injected: boolean, projectCwd?: string): Promise<MemoryRecord>;
412
+ /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
413
+ private setInjectedInner;
373
414
  /**
374
415
  * 修改一条记忆的内容/关键词(0.3.1 整理记忆用)。2026-09-15 起:改动**不再**
375
416
  * 退回待审核——静默机制下记忆由系统与模型自行维护,更新即保持原有审核状态;
@@ -379,6 +420,8 @@ export declare class MemoryEngine extends Service {
379
420
  content?: string;
380
421
  keywords?: string[];
381
422
  }, projectCwd?: string): Promise<MemoryRecord>;
423
+ /** {@link update} 的实现体;必须经 `writeScope` 进入。 */
424
+ private updateInner;
382
425
  /**
383
426
  * 预热某工作区的 project 表(异步打开并缓存)。system prompt 的
384
427
  * `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 = {}) {
@@ -273,7 +277,87 @@ export class MemoryEngine extends Service {
273
277
  * 命中危险内容规则则隔离:`status` 留 `suggested`(天然不注入)**并且**置
274
278
  * `quarantined`(不进检索),双保险,等待人工放行或删除。
275
279
  */
280
+ // ── 多实例写入协调(2026-09-17)────────────────────────────────────────
281
+ //
282
+ // 另一个 DSH 实例(web 与 SSiD 同时运行)共用同一个 DSH_HOME 时,它写的记忆只落在
283
+ // 磁盘上;我们的内存态若比磁盘旧,下一次整份覆盖就会把对方的新记忆抹掉。DSH 的
284
+ // 存储层明确不做跨进程协调(storage-json「writer per process and last-write-wins
285
+ // is correct」、storage-sqlite「cross-process coordination is out of scope」),
286
+ // 所以协调放在这里:开写前比对存储文件指纹,变过就重载——等价于「update 前先
287
+ // select」。残余窗口只剩两个实例真正同时写,而记忆写入是低频操作。
288
+ /** 文件指纹:mtime + size(size 兜住同一毫秒内的两次写)。 */
289
+ static fileStamp(file) {
290
+ try {
291
+ const stat = statSync(file);
292
+ return `${stat.mtimeMs}:${stat.size}`;
293
+ }
294
+ catch {
295
+ return 'missing';
296
+ }
297
+ }
298
+ /** 全局记忆的存储文件(JsonStorageBackend 的 unit 文件名 = domain 名)。 */
299
+ globalStoreFile() {
300
+ return join(this.config.globalRoot ?? globalRoot(), 'memory.json');
301
+ }
302
+ /** 某工作区记忆的存储文件。 */
303
+ projectStoreFile(projectCwd) {
304
+ const key = join(projectCwd);
305
+ return join(this.projectRootFor(key), `${this.projectBackendName(key)}.json`);
306
+ }
307
+ /** 记录当前指纹,作为「这就是我们造成的状态」的基准(写入完成后调用)。 */
308
+ noteStoreStamps(projectCwd) {
309
+ const global = this.globalStoreFile();
310
+ this.storeStamps.set(global, MemoryEngine.fileStamp(global));
311
+ const scopes = new Set(this.projectTables.keys());
312
+ if (projectCwd !== undefined && projectCwd !== '')
313
+ scopes.add(join(projectCwd));
314
+ for (const key of scopes) {
315
+ const file = this.projectStoreFile(key);
316
+ this.storeStamps.set(file, MemoryEngine.fileStamp(file));
317
+ }
318
+ }
319
+ /**
320
+ * 写前新鲜度门:存储文件被外部改过就重载,避免整份覆盖吃掉对方的写入。
321
+ * 必须在取表**之前**调用——{@link reload} 重开 facility,此前取到的 table 引用会失效。
322
+ */
323
+ async refreshForWrite(projectCwd) {
324
+ if (this.storeStamps.size === 0) {
325
+ this.noteStoreStamps(projectCwd);
326
+ return;
327
+ }
328
+ const files = [this.globalStoreFile()];
329
+ if (projectCwd !== undefined && projectCwd !== '')
330
+ files.push(this.projectStoreFile(projectCwd));
331
+ for (const file of files) {
332
+ if (MemoryEngine.fileStamp(file) === this.storeStamps.get(file))
333
+ continue;
334
+ await this.reload();
335
+ this.noteStoreStamps(projectCwd);
336
+ return;
337
+ }
338
+ }
339
+ /**
340
+ * 一次写入的完整作用域:串行化 → 新鲜度门 → 执行 → 记指纹。
341
+ * 刷新与「读—改—写」必须同处一个互斥区,否则并发写会在两者之间穿插。
342
+ * @param projectCwd - 本次写入涉及的工作区;无则只覆盖全局存储。
343
+ * @param run - 实际的读改写;必须在内部重新取表(刷新后旧引用失效)。
344
+ * @returns `run` 的返回值。
345
+ */
346
+ writeScope(projectCwd, run) {
347
+ const task = this.writeChain.then(async () => {
348
+ await this.refreshForWrite(projectCwd);
349
+ const result = await run();
350
+ this.noteStoreStamps(projectCwd);
351
+ return result;
352
+ });
353
+ this.writeChain = task.then(() => undefined, () => undefined);
354
+ return task;
355
+ }
276
356
  async remember(input, projectCwd) {
357
+ return this.writeScope(projectCwd, () => this.rememberInner(input, projectCwd));
358
+ }
359
+ /** {@link remember} 的实现体;必须经 `writeScope` 进入(多实例写入协调)。 */
360
+ async rememberInner(input, projectCwd) {
277
361
  const namespace = input.namespace ?? 'global';
278
362
  const table = namespace === 'project'
279
363
  ? await this.projectTableFor(projectCwd)
@@ -479,13 +563,17 @@ export class MemoryEngine extends Service {
479
563
  }
480
564
  /** 命中标记:写回 lastUsedAt 与命中次数,达标即自动升常驻(2026-09-15)。 */
481
565
  async markUsed(ids, projectCwd) {
566
+ return this.writeScope(projectCwd, () => this.markUsedInner(ids, projectCwd));
567
+ }
568
+ /** {@link markUsed} 的实现体;必须经 `writeScope` 进入(`recordUse` 在此之内)。 */
569
+ async markUsedInner(ids, projectCwd) {
482
570
  if (ids.length === 0)
483
571
  return;
484
572
  const now = Date.now();
485
573
  const global = this.requireTable('global');
486
- const project = projectCwd === undefined || projectCwd === ''
487
- ? undefined
488
- : this.projectTables.get(join(projectCwd));
574
+ // 取表走异步版:`writeScope` 可能在本次进入前刚 `reload()`(外部实例改过存储),
575
+ // 那时同步缓存已被清空——继续读缓存会把 project 侧的命中静默漏记。
576
+ const project = await this.projectTableFor(projectCwd);
489
577
  for (const id of ids) {
490
578
  const globalBlock = global.get(id);
491
579
  if (globalBlock !== undefined) {
@@ -625,6 +713,10 @@ export class MemoryEngine extends Service {
625
713
  * @returns 被撤下的条数(诊断与日志用)。
626
714
  */
627
715
  async demoteStale(projectCwd, now = Date.now()) {
716
+ return this.writeScope(projectCwd, () => this.demoteStaleInner(projectCwd, now));
717
+ }
718
+ /** {@link demoteStale} 的实现体;必须经 `writeScope` 进入。 */
719
+ async demoteStaleInner(projectCwd, now = Date.now()) {
628
720
  const threshold = now - AUTO_DEMOTE_DAYS * 24 * 60 * 60 * 1000;
629
721
  const tables = [this.requireTable('global')];
630
722
  if (projectCwd !== undefined && projectCwd !== '') {
@@ -664,6 +756,10 @@ export class MemoryEngine extends Service {
664
756
  * @returns 本次状态发生变化的条数。
665
757
  */
666
758
  async verifyAnchors(probes, projectCwd) {
759
+ return this.writeScope(projectCwd, () => this.verifyAnchorsInner(probes, projectCwd));
760
+ }
761
+ /** {@link verifyAnchors} 的实现体;必须经 `writeScope` 进入。 */
762
+ async verifyAnchorsInner(probes, projectCwd) {
667
763
  const tables = [this.requireTable('global')];
668
764
  if (projectCwd !== undefined && projectCwd !== '') {
669
765
  const project = await this.projectTableFor(projectCwd);
@@ -713,6 +809,10 @@ export class MemoryEngine extends Service {
713
809
  * @returns 迁移统计(放行 / 隔离 / 回填计数)。
714
810
  */
715
811
  async migrateLegacy(projectCwd) {
812
+ return this.writeScope(projectCwd, () => this.migrateLegacyInner(projectCwd));
813
+ }
814
+ /** {@link migrateLegacy} 的实现体;必须经 `writeScope` 进入。 */
815
+ async migrateLegacyInner(projectCwd) {
716
816
  const tables = [this.requireTable('global')];
717
817
  if (projectCwd !== undefined && projectCwd !== '') {
718
818
  const project = await this.projectTableFor(projectCwd);
@@ -778,6 +878,10 @@ export class MemoryEngine extends Service {
778
878
  return stats;
779
879
  }
780
880
  async forget(id, projectCwd) {
881
+ return this.writeScope(projectCwd, () => this.forgetInner(id, projectCwd));
882
+ }
883
+ /** {@link forget} 的实现体;必须经 `writeScope` 进入。 */
884
+ async forgetInner(id, projectCwd) {
781
885
  if (await this.requireTable('global').delete(id)) {
782
886
  this.ctx.emit('memory/changed', { operation: 'forgotten', id });
783
887
  return true;
@@ -790,6 +894,10 @@ export class MemoryEngine extends Service {
790
894
  return false;
791
895
  }
792
896
  async setStatus(id, status, projectCwd) {
897
+ return this.writeScope(projectCwd, () => this.setStatusInner(id, status, projectCwd));
898
+ }
899
+ /** {@link setStatus} 的实现体;必须经 `writeScope` 进入。 */
900
+ async setStatusInner(id, status, projectCwd) {
793
901
  const global = this.requireTable('global').get(id);
794
902
  if (global !== undefined) {
795
903
  const updated = {
@@ -830,6 +938,10 @@ export class MemoryEngine extends Service {
830
938
  * 「常驻注入」开关调用(remote.setInjected)。
831
939
  */
832
940
  async setInjected(id, injected, projectCwd) {
941
+ return this.writeScope(projectCwd, () => this.setInjectedInner(id, injected, projectCwd));
942
+ }
943
+ /** {@link setInjected} 的实现体;必须经 `writeScope` 进入。 */
944
+ async setInjectedInner(id, injected, projectCwd) {
833
945
  const locate = async () => {
834
946
  const global = this.requireTable('global').get(id);
835
947
  if (global !== undefined)
@@ -872,6 +984,10 @@ export class MemoryEngine extends Service {
872
984
  * `injected` 保留原值。若新内容命中危险规则则转为隔离(与写入路径同一判定)。
873
985
  */
874
986
  async update(id, patch, projectCwd) {
987
+ return this.writeScope(projectCwd, () => this.updateInner(id, patch, projectCwd));
988
+ }
989
+ /** {@link update} 的实现体;必须经 `writeScope` 进入。 */
990
+ async updateInner(id, patch, projectCwd) {
875
991
  const applyPatch = (block) => {
876
992
  const normalized = normalizeBlock(block);
877
993
  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.1",
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"