@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.
@@ -1,22 +1,30 @@
1
- // System-level sleep (v0.4.1): an idle-triggered, LLM-assisted deep pass over
2
- // the memory store. Three independent, fail-safe phases:
1
+ // System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
2
+ // the memory store. Four independent, fail-safe phases:
3
3
  // 1. conflict resolution — high-similarity same-type pairs are either parked
4
4
  // for review (freeze mode) or adjudicated by the LLM (winner kept / loser
5
- // archived), reusing the dream conflict machinery.
5
+ // archived), reusing the dream conflict machinery. Strictness-graded.
6
6
  // 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
7
7
  // to a one-line summary with the full body moved to _full_content; past
8
- // sleepDeepArchiveDays they are archived outright.
8
+ // sleepCompressDays they are archived outright.
9
9
  // 3. pattern discovery — the LLM scans the most recent memories and mints
10
10
  // type=pattern entries carrying evidence id references.
11
+ // 4. relation completion — orphan entities (zero relations) get implied
12
+ // relations completed from memory co-occurrence.
11
13
  // Each phase is wrapped so one failure never aborts the others, and a missing
12
- // LLM route / semantic embedder only skips the phases that need it.
14
+ // LLM route / semantic embedder only skips the phases that need it. A run is
15
+ // abortable via an AbortController signal (user activity) — phases check the
16
+ // signal between batches so a running cycle yields promptly.
13
17
  import { randomUUID, createHash } from "node:crypto";
14
- import { validateDecisions, applyDecisions } from "./dream/decisions.js";
15
- import { findPotentialConflicts } from "./dream/clustering.js";
16
- import { buildReceipt } from "./dream.js";
18
+ import { validateDecisions, applyDecisions } from "./decisions.js";
19
+ import { findPotentialConflicts } from "./clustering.js";
20
+ import { buildReceipt } from "../dream.js";
17
21
 
18
22
  const SUMMARY_MAX = 120;
19
- const CONFLICT_THRESHOLD = 0.85;
23
+ // Conflict similarity threshold per strictness level (v0.4.0):
24
+ // gentle only high-confidence pairs (0.92) — first-time users
25
+ // normal standard dream-level (0.85) — default
26
+ // aggressive low-confidence pairs too (0.75) — bloated stores
27
+ const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
20
28
 
21
29
  const CONFLICT_PROMPT = `你是记忆库冲突仲裁助手。下面是检测到的高相似度记忆对,可能内容矛盾或重复。
22
30
  对每一对输出一个 decision 对象:
@@ -92,14 +100,17 @@ function makeSummary(m) {
92
100
  * conflict_pending for human review (no LLM). Otherwise the LLM adjudicates:
93
101
  * each pair → winner kept / loser archived. Returns a per-run summary.
94
102
  */
95
- async function phaseConflicts(ctx, service, config, logger, runId, semantic = null) {
103
+ async function phaseConflicts(ctx, service, config, logger, runId, semantic = null, signal = null) {
96
104
  const embedder = semantic?.embedder;
97
105
  const vectorIndex = semantic?.vectorIndex;
98
106
  if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
99
107
  return { status: "skipped", reason: "no semantic embedder" };
100
108
  }
109
+ const strictness = config.sleepConflictStrictness ?? "normal";
110
+ const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
101
111
  const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
102
112
  if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
113
+ if (signal?.aborted) return { status: "aborted", reason: "user activity" };
103
114
 
104
115
  // Backfill + collect vectors for every eligible memory (best effort).
105
116
  const vectors = new Array(memories.length);
@@ -131,7 +142,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
131
142
  const usableMemories = usable.map((i) => memories[i]);
132
143
  const usableVectors = usable.map((i) => vectors[i]);
133
144
 
134
- const pairs = findPotentialConflicts(usableMemories, usableVectors, CONFLICT_THRESHOLD);
145
+ const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
135
146
  if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
136
147
 
137
148
  // Dedupe: each memory participates in at most one pair, highest similarity
@@ -218,20 +229,21 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
218
229
  * truncations, and the full body is preserved in _full_content so nothing is
219
230
  * lost. Deterministic and cheap, so it runs even with no LLM route.
220
231
  */
221
- function phaseDemotion(service, config, logger, runId) {
232
+ function phaseDemotion(service, config, logger, runId, signal = null) {
222
233
  const archiveDays = config.sleepArchiveDays ?? 30;
223
- const deepArchiveDays = config.sleepDeepArchiveDays ?? 90;
234
+ const compressDays = config.sleepCompressDays ?? 90;
224
235
  const archiveCut = Date.now() - archiveDays * 86400000;
225
- const deepCut = Date.now() - deepArchiveDays * 86400000;
236
+ const compressCut = Date.now() - compressDays * 86400000;
226
237
  const demoted = [];
227
238
  const archived = [];
228
239
  for (const m of service.all()) {
240
+ if (signal?.aborted) break;
229
241
  if (m.archived || m.forgotten) continue;
230
242
  const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
231
243
  if (!ref) continue;
232
244
  const t = new Date(ref).getTime();
233
245
  if (Number.isNaN(t)) continue;
234
- if (t < deepCut) {
246
+ if (t < compressCut) {
235
247
  service.setArchived(m.id, true);
236
248
  archived.push(m.id);
237
249
  } else if (t < archiveCut) {
@@ -254,20 +266,21 @@ function phaseDemotion(service, config, logger, runId) {
254
266
  * The empty snapshot is intentional: create claims no existing id, so the
255
267
  * "every id claimed" invariant is trivially satisfied for pure-create lists.
256
268
  */
257
- async function phasePatterns(ctx, service, config, logger, runId) {
269
+ async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
258
270
  const route = resolveSleepRoute(ctx, config, logger);
259
271
  if (!route) return { status: "skipped", reason: "no llm route" };
260
- const limit = config.sleepPatternScanCount ?? 100;
272
+ const limit = config.sleepPatternMinMemories ?? 100;
261
273
  const memories = service
262
274
  .list({ limit: 200, includeForgotten: false })
263
275
  .filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
264
276
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
265
277
  .slice(0, limit);
266
278
  if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
279
+ if (signal?.aborted) return { status: "aborted", reason: "user activity" };
267
280
  const listText = memories
268
281
  .map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
269
282
  .join("\n");
270
- const maxPatterns = config.sleepMaxPatterns ?? 5;
283
+ const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
271
284
  const text = await streamText(ctx, {
272
285
  provider: route.provider,
273
286
  model: route.model,
@@ -305,6 +318,61 @@ async function phasePatterns(ctx, service, config, logger, runId) {
305
318
  };
306
319
  }
307
320
 
321
+ /**
322
+ * Phase 4 — entity relation completion. Detects orphan entities (zero
323
+ * relations) and completes implied relations from memory co-occurrence:
324
+ * entities named in the same memory → related_to; container kinds
325
+ * (project/module) → part_of; tech-ish pairs → depends_on. Deterministic,
326
+ * no LLM — cheap, so it runs even without a route. saveRelation is
327
+ * bookkeeping (no write hook), so it never re-triggers the scheduler.
328
+ */
329
+ function inferRelationType(a, b) {
330
+ if ((a.type === "project" || a.type === "module") && a.type !== b.type) return "part_of";
331
+ if ((b.type === "project" || b.type === "module") && b.type !== a.type) return "part_of";
332
+ if (/npm|plugin|api|sdk|lib|framework|package|deps?|build/i.test(`${a.name} ${b.name}`)) return "depends_on";
333
+ return "related_to";
334
+ }
335
+
336
+ function phaseRelations(service, config, logger, runId, signal = null) {
337
+ const entities = service.listEntities({ limit: 1000 }) ?? [];
338
+ if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
339
+ const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
340
+ if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
341
+ const memories = service.all().filter((m) => !m.archived && !m.forgotten);
342
+ const seen = new Set();
343
+ const related = [];
344
+ const MAX_RELATIONS_PER_ORPHAN = 3;
345
+ for (const o of orphans) {
346
+ if (signal?.aborted) break;
347
+ let made = 0;
348
+ for (const m of memories) {
349
+ if (signal?.aborted || made >= MAX_RELATIONS_PER_ORPHAN) break;
350
+ const text = `${m.title ?? ""} ${m.content ?? ""}`;
351
+ if (!text.includes(o.name)) continue;
352
+ for (const other of entities) {
353
+ if (other.id === o.id || other.name === o.name) continue;
354
+ const key = [o.id, other.id].sort().join("|");
355
+ if (seen.has(key)) continue;
356
+ if (!text.includes(other.name)) continue;
357
+ const relationType = inferRelationType(o, other);
358
+ try {
359
+ service.saveRelation({ from_entity: o.id, to_entity: other.id, relation_type: relationType, memory_id: m.id, metadata: { source: "sleep_relation_completion" } });
360
+ seen.add(key);
361
+ related.push({ from: o.id, to: other.id, type: relationType });
362
+ made++;
363
+ } catch (error) {
364
+ logger?.warn?.(`dsh-mneme sleep: relation ${o.id}/${other.id} failed: ${String(error)}`);
365
+ }
366
+ }
367
+ }
368
+ }
369
+ return {
370
+ status: related.length > 0 ? "ok" : "noop",
371
+ orphanCount: orphans.length,
372
+ related
373
+ };
374
+ }
375
+
308
376
  // ---------------------------------------------------------------- run
309
377
 
310
378
  function deriveStatus(phases) {
@@ -323,10 +391,11 @@ function deriveStatus(phases) {
323
391
  * run_type='sleep' audit row (same dream_runs table) so sleep activity is
324
392
  * observable alongside consolidation runs.
325
393
  */
326
- export async function runSleep(ctx, service, config, logger, semantic = null) {
394
+ export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
327
395
  const runId = randomUUID();
328
396
  const phases = {};
329
397
  const attempt = async (name, fn) => {
398
+ if (signal?.aborted) return; // user resumed activity — stop before next phase
330
399
  try {
331
400
  phases[name] = await fn();
332
401
  } catch (error) {
@@ -334,9 +403,10 @@ export async function runSleep(ctx, service, config, logger, semantic = null) {
334
403
  logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
335
404
  }
336
405
  };
337
- await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic));
338
- await attempt("demotion", () => phaseDemotion(service, config, logger, runId));
339
- await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId));
406
+ await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
407
+ await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
408
+ await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
409
+ await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
340
410
 
341
411
  const status = deriveStatus(phases);
342
412
  const route = resolveSleepRoute(ctx, config, logger);
@@ -398,11 +468,12 @@ export function createSleepScheduler({
398
468
  let running = false;
399
469
  let disposed = false;
400
470
  let idleTimer = null;
471
+ let sleepAbort = null;
401
472
 
402
473
  function armIdleTimer() {
403
474
  if (disposed || idleTimer) return;
404
- if (config.sleepEnabled !== true) return;
405
- const idleMs = (config.sleepIdleMinutes ?? 30) * 60000;
475
+ if (config.sleepModeEnabled !== true) return;
476
+ const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
406
477
  const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
407
478
  idleTimer = setTimeoutFn(async () => {
408
479
  idleTimer = null;
@@ -413,18 +484,29 @@ export function createSleepScheduler({
413
484
 
414
485
  function shouldRun(at = now()) {
415
486
  if (disposed || running) return false;
416
- if (config.sleepEnabled !== true) return false;
417
- if (at - lastWriteAt < (config.sleepIdleMinutes ?? 30) * 60000) return false;
418
- if (at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
487
+ if (config.sleepModeEnabled !== true) return false;
488
+ if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
489
+ // lastRunAt === 0 means never ran the min-interval check must not block
490
+ // the very first cycle (a real run stamps a nonzero timestamp).
491
+ if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
419
492
  return true;
420
493
  }
421
494
 
422
495
  /** Called on writes: resets the idle clock and re-arms the fire timer. The
423
496
  * pending timer is cleared first — a stale timer armed against the old idle
424
497
  * window would otherwise fire early, fail shouldRun, and leave nothing armed
425
- * for the next window (a missed trigger until the next write). */
498
+ * for the next window (a missed trigger until the next write).
499
+ *
500
+ * While a sleep run is executing (running=true) the in-flight AbortController
501
+ * is NOT aborted: the run's own writes (demoteToSummary / setArchived ride
502
+ * the normal write-hook path) would otherwise self-abort the cycle. External
503
+ * activity during the run still resets the idle clock here, so no new cycle
504
+ * fires until the store is quiet again. */
426
505
  function noteWrite() {
427
506
  lastWriteAt = now();
507
+ if (!running && sleepAbort) {
508
+ sleepAbort.abort(); // user resumed activity — interrupt an idle run
509
+ }
428
510
  if (idleTimer) {
429
511
  clearTimeoutFn(idleTimer);
430
512
  idleTimer = null;
@@ -435,16 +517,19 @@ export function createSleepScheduler({
435
517
  async function maybeSchedule() {
436
518
  if (!shouldRun()) return false;
437
519
  running = true;
520
+ const abort = new AbortController();
521
+ sleepAbort = abort;
438
522
  try {
439
523
  lastRunAt = now();
440
524
  const result = await service.enqueue(() =>
441
- onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })
525
+ onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
442
526
  );
443
527
  return !!(result && result.ok);
444
528
  } catch (error) {
445
529
  logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
446
530
  return false;
447
531
  } finally {
532
+ sleepAbort = null;
448
533
  running = false;
449
534
  }
450
535
  }
@@ -455,6 +540,10 @@ export function createSleepScheduler({
455
540
  clearTimeoutFn(idleTimer);
456
541
  idleTimer = null;
457
542
  }
543
+ if (sleepAbort) {
544
+ sleepAbort.abort();
545
+ sleepAbort = null;
546
+ }
458
547
  }
459
548
 
460
549
  return { noteWrite, maybeSchedule, shouldRun, dispose };
package/src/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/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,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
  };