@modusensus/dsh-mneme 0.3.7 → 0.4.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/src/service.js CHANGED
@@ -9,6 +9,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
9
9
  // passed in the constructor). Fired on the same write events as onWrite.
10
10
  let dreamHook = null;
11
11
 
12
+ // Optional sleep scheduler hook (v0.4.1), installed via setSleepHook after
13
+ // creation. Fired on the same write events: it tells the sleep scheduler the
14
+ // store just changed so the idle-detection clock resets.
15
+ let sleepHook = null;
16
+
12
17
  // Optional vector embedder, installed via setEmbedder after creation. After
13
18
  // any content write it fire-and-forgets a re-embed of the row so vector
14
19
  // search stays in sync; failures are swallowed inside the embedder.
@@ -37,6 +42,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
37
42
  // replays them exactly once against the committed state.
38
43
  let txDepth = 0;
39
44
 
45
+ // Serial task queue (sleep v0.4.1). Long-running background passes — dream
46
+ // consolidation, sleep cycles — must never overlap: two sleep runs racing
47
+ // would double-demote or double-mint patterns. enqueue chains the task onto
48
+ // a promise tail so N callers can queue work that runs strictly one at a
49
+ // time. A task that rejects doesn't poison the queue (the tail swallows the
50
+ // rejection) but the rejection still propagates to that caller.
51
+ let queueTail = Promise.resolve();
52
+ function enqueue(fn) {
53
+ const next = queueTail.then(fn, fn);
54
+ queueTail = next.catch(() => {});
55
+ return next;
56
+ }
57
+
40
58
  // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
41
59
  // Ollama) exposes an async init(), so between `setEmbedder` and init()
42
60
  // resolving there is a window where embedSingle would throw "not initialized"
@@ -243,11 +261,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
243
261
  // entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
244
262
  if (config?.entitySearchEnabled) {
245
263
  if (q.startsWith("entity:")) {
246
- return searchByEntity(q.slice(7).trim(), options);
264
+ const hits = searchByEntity(q.slice(7).trim(), options);
265
+ touchRecalled(hits);
266
+ return hits;
247
267
  }
248
268
  if (q.startsWith("attr:")) {
249
269
  const [key, value] = q.slice(5).split("=");
250
- return searchByAttr(key, value, options);
270
+ const hits = searchByAttr(key, value, options);
271
+ touchRecalled(hits);
272
+ return hits;
251
273
  }
252
274
  }
253
275
 
@@ -345,6 +367,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
345
367
  });
346
368
  } catch { /* recall receipt is best effort */ }
347
369
  }
370
+ touchRecalled(result);
348
371
  return result;
349
372
  }
350
373
 
@@ -362,6 +385,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
362
385
  if (dreamHook) {
363
386
  try { dreamHook(); } catch { /* ignore */ }
364
387
  }
388
+ if (sleepHook) {
389
+ try { sleepHook(); } catch { /* ignore */ }
390
+ }
365
391
  }
366
392
 
367
393
  /**
@@ -384,10 +410,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
384
410
  throw error;
385
411
  } finally {
386
412
  txDepth--;
387
- try {
388
- syncMirror();
389
- } catch (error) {
390
- logger?.warn?.("syncMirror failed after transaction:", error);
413
+ // Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
414
+ // debt was already recorded by markMirrorDirty inside syncMirror, so a
415
+ // restart recovers — but the operator must see it now, not after restart.
416
+ const syncResult = syncMirror();
417
+ if (!syncResult?.success && !syncResult?.deferred) {
418
+ logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
391
419
  }
392
420
  notifyWrite();
393
421
  }
@@ -408,7 +436,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
408
436
  tags: memory.tags ?? existing.tags,
409
437
  title: memory.title ?? existing.title
410
438
  });
411
- syncMirror();
439
+ afterSync("write");
412
440
  notifyWrite();
413
441
  scheduleEmbed(merged);
414
442
  return { action: "merged", memory: merged };
@@ -421,13 +449,30 @@ export function createService({ store, mirror, config, onWrite, logger }) {
421
449
  importance: memory.importance ?? 3,
422
450
  source: memory.source ?? "manual"
423
451
  });
424
- syncMirror();
452
+ afterSync("write");
425
453
  notifyWrite();
426
454
  scheduleEmbed(created);
427
455
  scheduleEntityExtraction(created);
428
456
  return { action: "created", memory: created };
429
457
  }
430
458
 
459
+ /**
460
+ * Sleep touch (v0.4.1): when sleep is enabled, any memory surfaced by recall
461
+ * or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
462
+ * days → demote/archive" tiering counts real access. Best-effort and gated on
463
+ * config.sleepEnabled — when sleep is off this is a complete no-op (no writes
464
+ * on the hot recall path). A touch failure must never break search/inject.
465
+ */
466
+ function touchRecalled(memories) {
467
+ if (config?.sleepEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
468
+ for (const m of memories) {
469
+ if (!m?.id) continue;
470
+ try {
471
+ store.touchAccess(m.id);
472
+ } catch { /* touch is best effort */ }
473
+ }
474
+ }
475
+
431
476
  /**
432
477
  * Candidate memories for automatic context injection:
433
478
  * summaries first, then all preferences, then non-forgotten items with
@@ -444,7 +489,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
444
489
  const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
445
490
  return pa - pb || b.importance - a.importance;
446
491
  });
447
- return items.slice(0, maxItems);
492
+ const selected = items.slice(0, maxItems);
493
+ touchRecalled(selected);
494
+ return selected;
448
495
  }
449
496
 
450
497
  /**
@@ -481,7 +528,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
481
528
  }
482
529
  }
483
530
  if (applied) {
484
- syncMirror();
531
+ afterSync("write");
485
532
  notifyWrite();
486
533
  }
487
534
  return applied;
@@ -577,16 +624,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
577
624
  // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
578
625
  // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
579
626
  function syncMirror() {
580
- if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
627
+ if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
581
628
  const now = new Date().toISOString();
582
629
  let gen;
583
630
  try {
584
- // 绑定本次期望轮次,必须在任何渲染之前,避免制造幽灵债务
585
- const state = store.incrementGeneration();
586
- gen = state.generation;
631
+ // desired generation 已在业务写事务中原子递增(peer blocker 1);这里
632
+ // 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration
633
+ const state = store.getMirrorState();
634
+ gen = state?.generation ?? 0;
587
635
  } catch (stateError) {
588
- logger?.warn?.("syncMirror: incrementGeneration failed:", stateError);
589
- return;
636
+ logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
637
+ return { success: false, error: stateError?.message ?? String(stateError) };
590
638
  }
591
639
  // coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
592
640
  // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
@@ -603,37 +651,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
603
651
  // 全量渲染
604
652
  mirror.sync(reconcileHumanEdits(list));
605
653
 
606
- // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截
654
+ // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
655
+ // 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
607
656
  try {
608
657
  store.markMirrorCleanForGeneration(gen, now);
609
658
  } catch (stateError) {
610
659
  logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
660
+ return { success: false, error: stateError?.message ?? String(stateError) };
611
661
  }
612
- // 逐 type 标记为 clean
662
+ // 逐 type 标记为 committed(peer blocker 4: per-type receipt)
613
663
  for (const type of coveredTypes) {
614
664
  try {
615
- store.setTypeStatus(type, { dirty: false, applied_gen: gen, last_error: null });
665
+ store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
616
666
  } catch (stateError) {
617
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) clean failed:`, stateError);
667
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
618
668
  }
619
669
  }
670
+ return { success: true };
620
671
  } catch (error) {
621
672
  const errMsg = error?.message ?? String(error);
622
673
  logger?.warn?.("syncMirror failed:", error);
623
674
  try {
624
- // 债务绑定到新的一轮(desired generation +1)
675
+ // 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
676
+ // generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
625
677
  store.markMirrorDirty(errMsg, now);
626
678
  } catch (stateError) {
627
679
  logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
628
680
  }
629
- // 逐 type 标记为 dirty(applied_gen 不动)
681
+ // 逐 type 标记为 failed(applied_gen 不动)
630
682
  for (const type of coveredTypes) {
631
683
  try {
632
- store.setTypeStatus(type, { dirty: true, last_error: errMsg });
684
+ store.setTypeStatus(type, { status: "failed", last_error: errMsg });
633
685
  } catch (stateError) {
634
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) dirty failed:`, stateError);
686
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
635
687
  }
636
688
  }
689
+ return { success: false, error: errMsg };
690
+ }
691
+ }
692
+
693
+ // afterSync: run syncMirror and surface a failure to the operator instead of
694
+ // swallowing it (peer blocker 2). The mirror debt has already been persisted
695
+ // by markMirrorDirty inside syncMirror, so a restart recovers — but the
696
+ // calling write path must not report clean while the mirror is known-stale.
697
+ function afterSync(label) {
698
+ const r = syncMirror();
699
+ if (!r?.success && !r?.deferred) {
700
+ logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
637
701
  }
638
702
  }
639
703
 
@@ -718,10 +782,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
718
782
  success_at: state.success_at ?? null
719
783
  };
720
784
  } catch (error) {
721
- // fail-safe:状态读取失败也不向外抛
785
+ // fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
786
+ // 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
722
787
  logger?.warn?.("getMirrorHealth failed:", error);
723
788
  return {
724
- dirty: false,
789
+ dirty: null,
725
790
  last_error: error?.message ?? String(error),
726
791
  last_attempt: null,
727
792
  success_at: null
@@ -738,7 +803,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
738
803
  mergeHumanEdits,
739
804
  toApiList,
740
805
  transaction,
806
+ enqueue,
741
807
  setDreamHook(fn) { dreamHook = fn; },
808
+ setSleepHook(fn) { sleepHook = fn; },
742
809
  setEmbedder(emb) {
743
810
  embedder = emb;
744
811
  if (!emb) {
@@ -780,7 +847,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
780
847
  getById: (id) => store.getById(id),
781
848
  remove: (id) => {
782
849
  store.remove(id);
783
- syncMirror();
850
+ afterSync("write");
784
851
  notifyWrite();
785
852
  },
786
853
  update: (id, p, ctx = {}) => {
@@ -806,7 +873,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
806
873
  memory_id: id
807
874
  });
808
875
  }
809
- syncMirror();
876
+ afterSync("write");
810
877
  notifyWrite();
811
878
  scheduleEmbed(updated);
812
879
  return updated;
@@ -835,19 +902,24 @@ export function createService({ store, mirror, config, onWrite, logger }) {
835
902
  memory_id: id
836
903
  });
837
904
  }
838
- syncMirror();
905
+ afterSync("write");
839
906
  notifyWrite();
840
907
  scheduleEmbed(updated);
841
908
  return updated;
842
909
  },
843
910
  setForget: (id, f) => {
844
911
  const updated = store.setForget(id, f);
845
- syncMirror();
912
+ afterSync("write");
846
913
  return updated;
847
914
  },
848
915
  setArchived: (id, f) => {
849
916
  const updated = store.setArchived(id, f);
850
- syncMirror();
917
+ afterSync("write");
918
+ return updated;
919
+ },
920
+ demoteToSummary: (id, summary, opts) => {
921
+ const updated = store.demoteToSummary(id, summary, opts);
922
+ afterSync("write");
851
923
  return updated;
852
924
  },
853
925
  // autoDream audit trail: passthroughs deliberately bypass write hooks —