@modusensus/dsh-mneme 0.4.1 → 0.4.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/lib/index.js CHANGED
@@ -5,7 +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 "./sleep.js";
8
+ import { createSleepScheduler, runSleep } from "./dream/sleep.js";
9
9
  import { createApi } from "./api.js";
10
10
  import { createSettings } from "./settings.js";
11
11
  import { createCommandManager } from "./commands.js";
@@ -182,21 +182,18 @@ export const apply = (ctx, config) => {
182
182
  service.setDreamHook(() => dream.maybeSchedule(service));
183
183
  }
184
184
 
185
- // Sleep scheduler (v0.4.1): idle-triggered deep pass (conflict resolution +
186
- // archival demotion + pattern discovery). opt-in via sleepEnabled; writes
187
- // through the service reset the idle clock (setSleepHook), and when the store
188
- // stays quiet for sleepIdleMinutes past the sleepMinIntervalHours gate, the
189
- // scheduler fires one cycle. The onRun closure reuses the same semantic
190
- // pipeline as dream for the conflict phase.
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'.
191
190
  let sleep = null;
192
- if (cfg.sleepEnabled) {
191
+ if (cfg.sleepModeEnabled) {
193
192
  sleep = createSleepScheduler({
194
193
  service,
195
194
  config: cfg,
196
195
  logger: ctx.logger,
197
- onRun: () => (sleep
198
- ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex })
199
- : Promise.resolve({ ok: true, skipped: true }))
196
+ onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
200
197
  });
201
198
  service.setSleepHook(() => sleep.noteWrite());
202
199
  }
@@ -271,7 +268,7 @@ export const apply = (ctx, config) => {
271
268
  }
272
269
  commands?.dispose();
273
270
  if (dream) await dream.dispose();
274
- if (sleep) await sleep.dispose();
271
+ if (sleep) sleep.dispose();
275
272
  store.close();
276
273
  };
277
274
  };
package/lib/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/lib/service.js CHANGED
@@ -9,9 +9,9 @@ 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.
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
15
  let sleepHook = null;
16
16
 
17
17
  // Optional vector embedder, installed via setEmbedder after creation. After
@@ -42,7 +42,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
42
42
  // replays them exactly once against the committed state.
43
43
  let txDepth = 0;
44
44
 
45
- // Serial task queue (sleep v0.4.1). Long-running background passes — dream
45
+ // Serial task queue (sleep v0.4.0). Long-running background passes — dream
46
46
  // consolidation, sleep cycles — must never overlap: two sleep runs racing
47
47
  // would double-demote or double-mint patterns. enqueue chains the task onto
48
48
  // a promise tail so N callers can queue work that runs strictly one at a
@@ -205,7 +205,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
205
205
  for (const mem of keywordHits) {
206
206
  if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
207
207
  }
208
- 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;
209
211
  }
210
212
 
211
213
  /**
@@ -223,7 +225,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
223
225
  // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
224
226
  // 当前有效记忆(v0.3.0,store.js 已实现)。
225
227
  const rows = store.findMemoriesByAttr(key, value ?? "");
226
- return rows.slice(0, topK);
228
+ const hits = rows.slice(0, topK);
229
+ touchRecalled(hits);
230
+ return hits;
227
231
  }
228
232
 
229
233
  /**
@@ -253,6 +257,23 @@ export function createService({ store, mirror, config, onWrite, logger }) {
253
257
  return base * (0.5 + (row.importance ?? 3) / 10);
254
258
  }
255
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
+
256
277
  async function searchMemories(query, options = {}) {
257
278
  const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
258
279
  const q = String(query ?? "").trim();
@@ -261,15 +282,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
261
282
  // entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
262
283
  if (config?.entitySearchEnabled) {
263
284
  if (q.startsWith("entity:")) {
264
- const hits = searchByEntity(q.slice(7).trim(), options);
265
- touchRecalled(hits);
266
- return hits;
285
+ return searchByEntity(q.slice(7).trim(), options);
267
286
  }
268
287
  if (q.startsWith("attr:")) {
269
288
  const [key, value] = q.slice(5).split("=");
270
- const hits = searchByAttr(key, value, options);
271
- touchRecalled(hits);
272
- return hits;
289
+ return searchByAttr(key, value, options);
273
290
  }
274
291
  }
275
292
 
@@ -456,23 +473,6 @@ export function createService({ store, mirror, config, onWrite, logger }) {
456
473
  return { action: "created", memory: created };
457
474
  }
458
475
 
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
-
476
476
  /**
477
477
  * Candidate memories for automatic context injection:
478
478
  * summaries first, then all preferences, then non-forgotten items with
@@ -648,26 +648,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
648
648
  }
649
649
  }
650
650
 
651
- // 全量渲染
652
- mirror.sync(reconcileHumanEdits(list));
653
-
654
- // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
655
- // 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
656
- try {
657
- store.markMirrorCleanForGeneration(gen, now);
658
- } catch (stateError) {
659
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
660
- 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
+ }
661
673
  }
662
- // 逐 type 标记为 committed(peer blocker 4: per-type receipt)
663
- for (const type of coveredTypes) {
674
+
675
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
676
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
677
+ if (allOk) {
664
678
  try {
665
- store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
679
+ store.markMirrorCleanForGeneration(gen, now);
666
680
  } catch (stateError) {
667
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
681
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
682
+ return { success: false, error: stateError?.message ?? String(stateError) };
668
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);
669
696
  }
670
- return { success: true };
697
+ return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
671
698
  } catch (error) {
672
699
  const errMsg = error?.message ?? String(error);
673
700
  logger?.warn?.("syncMirror failed:", error);
@@ -691,14 +718,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
691
718
  }
692
719
 
693
720
  // 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.
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.
697
726
  function afterSync(label) {
698
727
  const r = syncMirror();
699
728
  if (!r?.success && !r?.deferred) {
700
729
  logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
701
730
  }
731
+ return r;
702
732
  }
703
733
 
704
734
  // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
@@ -873,9 +903,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
873
903
  memory_id: id
874
904
  });
875
905
  }
876
- afterSync("write");
906
+ const sync = afterSync("write");
877
907
  notifyWrite();
878
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
+ }
879
920
  return updated;
880
921
  },
881
922
  // Compare-and-set update: applies the patch only when the row still carries
@@ -902,9 +943,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
902
943
  memory_id: id
903
944
  });
904
945
  }
905
- afterSync("write");
946
+ const sync = afterSync("write");
906
947
  notifyWrite();
907
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
+ }
908
957
  return updated;
909
958
  },
910
959
  setForget: (id, f) => {
@@ -917,11 +966,22 @@ export function createService({ store, mirror, config, onWrite, logger }) {
917
966
  afterSync("write");
918
967
  return updated;
919
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.
920
973
  demoteToSummary: (id, summary, opts) => {
921
974
  const updated = store.demoteToSummary(id, summary, opts);
922
975
  afterSync("write");
923
976
  return updated;
924
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),
925
985
  // autoDream audit trail: passthroughs deliberately bypass write hooks —
926
986
  // an audit write is bookkeeping, and notifyWrite would loop back into the
927
987
  // dream scheduler that just recorded the run.
@@ -944,6 +1004,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
944
1004
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
945
1005
  // passthroughs above — never write-hook-triggering memory mutations.
946
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),
947
1013
  getAttrsByMemory: (id) => store.getAttrsByMemory(id),
948
1014
  migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
949
1015
  };