@modusensus/dsh-mneme 0.3.8 → 0.4.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/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { createTools } from "./tools.js";
5
5
  import { createInjector } from "./inject.js";
6
6
  import { createSummarizer } from "./summarize.js";
7
7
  import { createDreamScheduler } from "./dream.js";
8
+ import { createSleepScheduler, runSleep } from "./dream/sleep.js";
8
9
  import { createApi } from "./api.js";
9
10
  import { createSettings } from "./settings.js";
10
11
  import { createCommandManager } from "./commands.js";
@@ -181,6 +182,22 @@ export const apply = (ctx, config) => {
181
182
  service.setDreamHook(() => dream.maybeSchedule(service));
182
183
  }
183
184
 
185
+ // Sleep scheduler (v0.4.0): idle-triggered deep maintenance. Fires when the
186
+ // store has been quiet for sleepIdleMinutes and re-arms on every write via
187
+ // noteWrite (hooked to the service's write path). Runs go through
188
+ // service.enqueue so they serialize with autoDream — the two never overlap.
189
+ // Abortable on user activity; audited into dream_runs with run_type='sleep'.
190
+ let sleep = null;
191
+ if (cfg.sleepModeEnabled) {
192
+ sleep = createSleepScheduler({
193
+ service,
194
+ config: cfg,
195
+ logger: ctx.logger,
196
+ onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
197
+ });
198
+ service.setSleepHook(() => sleep.noteWrite());
199
+ }
200
+
184
201
  // Entity gene extraction (v0.3.0): wire the extractor into the service as a
185
202
  // hook so saveWithDedupe can fire-and-forget an extraction pass on fresh
186
203
  // writes. The service never sees ctx.llm — index.js adapts it here into the
@@ -251,6 +268,7 @@ export const apply = (ctx, config) => {
251
268
  }
252
269
  commands?.dispose();
253
270
  if (dream) await dream.dispose();
271
+ if (sleep) sleep.dispose();
254
272
  store.close();
255
273
  };
256
274
  };
package/src/mirror.js CHANGED
@@ -128,21 +128,33 @@ export function createMirror(dir) {
128
128
  for (const m of memories) {
129
129
  (byType[m.type] ??= []).push(m);
130
130
  }
131
+ // Per-type physical outcomes (audit peer D): a failed write for one type
132
+ // must not abort the whole render. Each type is written (or pruned) in its
133
+ // own try/catch and the result reported so the caller can persist per-type
134
+ // committed/failed receipts — a file that was already written is a real
135
+ // physical commit even when a sibling type errors.
136
+ const results = {};
131
137
  for (const type of Object.keys(TYPE_FILE)) {
132
- const file = filePath(type);
133
- const items = (byType[type] ?? [])
134
- .slice()
135
- .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
136
- if (items.length === 0) {
137
- // no memories of this type: drop any stale mirror file so deleted
138
- // memories do not "resurrect" via readHumanEdits
139
- rmSync(file, { force: true });
140
- continue;
138
+ try {
139
+ const file = filePath(type);
140
+ const items = (byType[type] ?? [])
141
+ .slice()
142
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
143
+ if (items.length === 0) {
144
+ // no memories of this type: drop any stale mirror file so deleted
145
+ // memories do not "resurrect" via readHumanEdits
146
+ rmSync(file, { force: true });
147
+ } else {
148
+ const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
149
+ const body = items.map(renderMemory).join("\n");
150
+ writeFileSync(file, header + body, "utf8");
151
+ }
152
+ results[type] = { ok: true };
153
+ } catch (error) {
154
+ results[type] = { ok: false, error: error?.message ?? String(error) };
141
155
  }
142
- const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
143
- const body = items.map(renderMemory).join("\n");
144
- writeFileSync(file, header + body, "utf8");
145
156
  }
157
+ return results;
146
158
  }
147
159
 
148
160
  return { filePath, sync, readHumanEdits };
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.0), installed via setSleepHook after
13
+ // creation. Fired on the same write events as onWrite: it tells the sleep
14
+ // scheduler the 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.0). 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"
@@ -187,7 +205,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
187
205
  for (const mem of keywordHits) {
188
206
  if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
189
207
  }
190
- return Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
208
+ const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
209
+ touchRecalled(hits);
210
+ return hits;
191
211
  }
192
212
 
193
213
  /**
@@ -205,7 +225,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
205
225
  // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
206
226
  // 当前有效记忆(v0.3.0,store.js 已实现)。
207
227
  const rows = store.findMemoriesByAttr(key, value ?? "");
208
- return rows.slice(0, topK);
228
+ const hits = rows.slice(0, topK);
229
+ touchRecalled(hits);
230
+ return hits;
209
231
  }
210
232
 
211
233
  /**
@@ -235,6 +257,23 @@ export function createService({ store, mirror, config, onWrite, logger }) {
235
257
  return base * (0.5 + (row.importance ?? 3) / 10);
236
258
  }
237
259
 
260
+ /**
261
+ * Sleep touch (v0.4.0): when sleep is enabled, any memory surfaced by recall
262
+ * or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
263
+ * days → demote/archive" tiering counts real access. Best-effort and gated on
264
+ * config.sleepModeEnabled — when sleep is off this is a complete no-op (no
265
+ * writes on the hot recall path). A touch failure must never break search/inject.
266
+ */
267
+ function touchRecalled(memories) {
268
+ if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
269
+ for (const m of memories) {
270
+ if (!m?.id) continue;
271
+ try {
272
+ store.touchLastAccess(m.id);
273
+ } catch { /* touch is best effort */ }
274
+ }
275
+ }
276
+
238
277
  async function searchMemories(query, options = {}) {
239
278
  const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
240
279
  const q = String(query ?? "").trim();
@@ -345,6 +384,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
345
384
  });
346
385
  } catch { /* recall receipt is best effort */ }
347
386
  }
387
+ touchRecalled(result);
348
388
  return result;
349
389
  }
350
390
 
@@ -362,6 +402,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
362
402
  if (dreamHook) {
363
403
  try { dreamHook(); } catch { /* ignore */ }
364
404
  }
405
+ if (sleepHook) {
406
+ try { sleepHook(); } catch { /* ignore */ }
407
+ }
365
408
  }
366
409
 
367
410
  /**
@@ -446,7 +489,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
446
489
  const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
447
490
  return pa - pb || b.importance - a.importance;
448
491
  });
449
- return items.slice(0, maxItems);
492
+ const selected = items.slice(0, maxItems);
493
+ touchRecalled(selected);
494
+ return selected;
450
495
  }
451
496
 
452
497
  /**
@@ -603,26 +648,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
603
648
  }
604
649
  }
605
650
 
606
- // 全量渲染
607
- mirror.sync(reconcileHumanEdits(list));
608
-
609
- // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
610
- // 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
611
- try {
612
- store.markMirrorCleanForGeneration(gen, now);
613
- } catch (stateError) {
614
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
615
- return { success: false, error: stateError?.message ?? String(stateError) };
651
+ // Per-type physical outcome (audit peer D): mirror.sync writes each type
652
+ // file independently and reports per-type success/failure. A type whose
653
+ // file was physically committed must be marked committed even when a
654
+ // sibling type errors the old code batch-failed every type on any error,
655
+ // leaving committed files mislabeled as failed and masking partial state.
656
+ // Absent entries (a type with no memories) count as success: sync prunes
657
+ // the stale file, which is itself a completed physical state.
658
+ let allOk = true;
659
+ const results = mirror.sync(reconcileHumanEdits(list)) ?? {};
660
+ for (const type of Object.keys(TYPE_FILE)) {
661
+ const r = results[type];
662
+ const ok = !r || r.ok === true;
663
+ if (!ok) allOk = false;
664
+ try {
665
+ if (ok) {
666
+ store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
667
+ } else {
668
+ store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
669
+ }
670
+ } catch (stateError) {
671
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
672
+ }
616
673
  }
617
- // 逐 type 标记为 committed(peer blocker 4: per-type receipt)
618
- for (const type of coveredTypes) {
674
+
675
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
676
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
677
+ if (allOk) {
619
678
  try {
620
- store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
679
+ store.markMirrorCleanForGeneration(gen, now);
621
680
  } catch (stateError) {
622
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
681
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
682
+ return { success: false, error: stateError?.message ?? String(stateError) };
623
683
  }
684
+ return { success: true };
685
+ }
686
+
687
+ // 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
688
+ // 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
689
+ const failedTypes = Object.entries(results)
690
+ .filter(([, r]) => r && r.ok === false)
691
+ .map(([t]) => t);
692
+ try {
693
+ store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
694
+ } catch (stateError) {
695
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
624
696
  }
625
- return { success: true };
697
+ return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
626
698
  } catch (error) {
627
699
  const errMsg = error?.message ?? String(error);
628
700
  logger?.warn?.("syncMirror failed:", error);
@@ -646,14 +718,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
646
718
  }
647
719
 
648
720
  // afterSync: run syncMirror and surface a failure to the operator instead of
649
- // swallowing it (peer blocker 2). The mirror debt has already been persisted
650
- // by markMirrorDirty inside syncMirror, so a restart recovers — but the
651
- // calling write path must not report clean while the mirror is known-stale.
721
+ // swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
722
+ // been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
723
+ // but the calling write path must not report clean while the mirror is
724
+ // known-stale. Returns the sync result so the caller can attach an explicit
725
+ // degraded/pending receipt to its return value instead of faking success.
652
726
  function afterSync(label) {
653
727
  const r = syncMirror();
654
728
  if (!r?.success && !r?.deferred) {
655
729
  logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
656
730
  }
731
+ return r;
657
732
  }
658
733
 
659
734
  // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
@@ -758,7 +833,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
758
833
  mergeHumanEdits,
759
834
  toApiList,
760
835
  transaction,
836
+ enqueue,
761
837
  setDreamHook(fn) { dreamHook = fn; },
838
+ setSleepHook(fn) { sleepHook = fn; },
762
839
  setEmbedder(emb) {
763
840
  embedder = emb;
764
841
  if (!emb) {
@@ -826,9 +903,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
826
903
  memory_id: id
827
904
  });
828
905
  }
829
- afterSync("write");
906
+ const sync = afterSync("write");
830
907
  notifyWrite();
831
908
  scheduleEmbed(updated);
909
+ // Audit peer B: when the mirror sync failed, the store write landed but
910
+ // the mirror did not converge — return an explicit degraded receipt rather
911
+ // than a plain success. Non-enumerable so existing deepEqual assertions on
912
+ // the memory shape keep passing.
913
+ if (!sync?.success && !sync?.deferred) {
914
+ Object.defineProperty(updated, "_mirror", {
915
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
916
+ enumerable: false,
917
+ configurable: true
918
+ });
919
+ }
832
920
  return updated;
833
921
  },
834
922
  // Compare-and-set update: applies the patch only when the row still carries
@@ -855,9 +943,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
855
943
  memory_id: id
856
944
  });
857
945
  }
858
- afterSync("write");
946
+ const sync = afterSync("write");
859
947
  notifyWrite();
860
948
  scheduleEmbed(updated);
949
+ // Audit peer B: mirror sync failure on a CAS write must surface too.
950
+ if (!sync?.success && !sync?.deferred) {
951
+ Object.defineProperty(updated, "_mirror", {
952
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
953
+ enumerable: false,
954
+ configurable: true
955
+ });
956
+ }
861
957
  return updated;
862
958
  },
863
959
  setForget: (id, f) => {
@@ -870,6 +966,22 @@ export function createService({ store, mirror, config, onWrite, logger }) {
870
966
  afterSync("write");
871
967
  return updated;
872
968
  },
969
+ // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
970
+ // content so they ride the normal write-hook path (mirror re-renders).
971
+ // touchLastAccess is a read-stamp — deliberately NO write hook (a recall
972
+ // must not dirty the mirror). getUnrecalledSince is a pure read.
973
+ demoteToSummary: (id, summary, opts) => {
974
+ const updated = store.demoteToSummary(id, summary, opts);
975
+ afterSync("write");
976
+ return updated;
977
+ },
978
+ restoreContent: (id) => {
979
+ const updated = store.restoreContent(id);
980
+ afterSync("write");
981
+ return updated;
982
+ },
983
+ touchLastAccess: (id, at) => store.touchLastAccess(id, at),
984
+ getUnrecalledSince: (cutMs, opts) => store.getUnrecalledSince(cutMs, opts),
873
985
  // autoDream audit trail: passthroughs deliberately bypass write hooks —
874
986
  // an audit write is bookkeeping, and notifyWrite would loop back into the
875
987
  // dream scheduler that just recorded the run.
@@ -892,6 +1004,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
892
1004
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
893
1005
  // passthroughs above — never write-hook-triggering memory mutations.
894
1006
  saveRelation: (r) => store.saveRelation(r),
1007
+ listEntities: (o) => store.listEntities(o),
1008
+ getRelations: (id) => store.getRelations(id),
1009
+ saveAttr: (r) => store.saveAttr(r),
1010
+ createEntity: (r) => store.createEntity(r),
1011
+ findEntityByName: (n) => store.findEntityByName(n),
1012
+ findEntityById: (id) => store.findEntityById(id),
895
1013
  getAttrsByMemory: (id) => store.getAttrsByMemory(id),
896
1014
  migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
897
1015
  };