@modusensus/dsh-mneme 0.4.1 → 0.4.3-beta.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/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
  };
package/src/store.js CHANGED
@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS memories (
13
13
  archived INTEGER NOT NULL DEFAULT 0,
14
14
  source TEXT,
15
15
  embedding TEXT,
16
+ last_accessed_at TEXT,
17
+ _full_content TEXT,
16
18
  created_at TEXT NOT NULL,
17
19
  updated_at TEXT NOT NULL
18
20
  );
@@ -38,7 +40,8 @@ CREATE TABLE IF NOT EXISTS dream_runs (
38
40
  applied INTEGER NOT NULL DEFAULT 0,
39
41
  summary_stored INTEGER NOT NULL DEFAULT 0,
40
42
  receipt TEXT NOT NULL,
41
- policy_epoch INTEGER NOT NULL DEFAULT 0 -- 裁决规则版本:规则升级后旧裁决降级为历史证据
43
+ policy_epoch INTEGER NOT NULL DEFAULT 0, -- 裁决规则版本:规则升级后旧裁决降级为历史证据
44
+ run_type TEXT NOT NULL DEFAULT 'auto' -- auto | sleep:睡眠周期的审计区分
42
45
  );
43
46
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
44
47
 
@@ -180,8 +183,8 @@ CREATE TABLE IF NOT EXISTS mirror_state (
180
183
  last_error TEXT, -- 最近失败原因
181
184
  last_attempt TEXT, -- 最近尝试时间(ISO)
182
185
  success_at TEXT, -- 最近成功时间(ISO)
183
- generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991), -- 期望的同步轮次(desired)
184
- applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991), -- 已成功应用的轮次
186
+ generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
187
+ applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
185
188
  type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
186
189
  );
187
190
  `;
@@ -228,9 +231,6 @@ function toRow(row) {
228
231
  source: row.source ?? undefined,
229
232
  created_at: row.created_at,
230
233
  updated_at: row.updated_at,
231
- // Sleep (v0.4.1): last_accessed_at drives the unrecalled tiering;
232
- // _full_content holds the pre-demotion body for a memory reduced to its
233
- // summary. Both are internal — the mirror render must not expose them.
234
234
  last_accessed_at: row.last_accessed_at ?? undefined,
235
235
  _full_content: row._full_content ?? undefined
236
236
  };
@@ -397,11 +397,14 @@ function parseJsonArray(raw) {
397
397
 
398
398
  export function createStore(path) {
399
399
  const db = new DatabaseSync(path);
400
- db.exec("PRAGMA journal_mode = WAL;");
401
- // Concurrent writers (peer probe: 8 independent processes) must wait for the
402
- // write lock instead of failing immediately with SQLITE_BUSY otherwise the
403
- // atomic generation increment loses whole writes, not just increments.
400
+ // Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
401
+ // init). Switching a fresh DB to WAL takes an exclusive lock; when several
402
+ // processes open the same path simultaneously, that lock can fail with
403
+ // SQLITE_BUSY before the timeout is armed. With the timeout installed first,
404
+ // the WAL transition (and every later write) blocks and retries instead of
405
+ // failing outright, so concurrent init converges to a stable 447/447.
404
406
  db.exec("PRAGMA busy_timeout = 5000;");
407
+ db.exec("PRAGMA journal_mode = WAL;");
405
408
  db.exec(SCHEMA);
406
409
 
407
410
  // Schema migrations for legacy databases (idempotent).
@@ -412,6 +415,12 @@ export function createStore(path) {
412
415
  if (!columns.includes("embedding")) {
413
416
  db.exec("ALTER TABLE memories ADD COLUMN embedding TEXT");
414
417
  }
418
+ if (!columns.includes("last_accessed_at")) {
419
+ db.exec("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
420
+ }
421
+ if (!columns.includes("_full_content")) {
422
+ db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
423
+ }
415
424
 
416
425
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
417
426
  const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
@@ -422,16 +431,6 @@ export function createStore(path) {
422
431
  db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
423
432
  }
424
433
 
425
- // Sleep (v0.4.1) columns: last_accessed_at tracks recall/inject touch for the
426
- // "unrecalled N days → demote/archive" tiering; _full_content holds the pre-demotion
427
- // body when a memory is reduced to its summary. Both are additive-only.
428
- if (!columns.includes("last_accessed_at")) {
429
- db.exec("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
430
- }
431
- if (!columns.includes("_full_content")) {
432
- db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
433
- }
434
-
435
434
  // Legacy mirror_state without v0.3.6 generation columns → add each missing
436
435
  // column idempotently (old DBs open cleanly, no data loss).
437
436
  const mirrorCols = db.prepare("PRAGMA table_info(mirror_state)").all().map((c) => c.name);
@@ -445,6 +444,24 @@ export function createStore(path) {
445
444
  db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
446
445
  }
447
446
 
447
+ // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
448
+ // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
449
+ // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
450
+ // round — so surface it as a hard error on open instead of silently reading it
451
+ // as a coherent generation. Fail-closed: the operator must repair or reset the
452
+ // state row rather than continue with a lie.
453
+ for (const col of ["generation", "applied_generation"]) {
454
+ const bad = db.prepare(
455
+ `SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
456
+ ).get();
457
+ if (bad) {
458
+ throw new RangeError(
459
+ `mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
460
+ `repair or reset the row before opening this database`
461
+ );
462
+ }
463
+ }
464
+
448
465
  // Per-instance monotonic timestamp guard: consecutive writes within the same
449
466
  // millisecond must still produce strictly increasing timestamps (test asserts
450
467
  // updated_at != created_at). State lives in the store closure, not module scope.
@@ -571,24 +588,35 @@ export function createStore(path) {
571
588
  const embedding = patch.embedding !== undefined
572
589
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
573
590
  : existing.embedding ?? null;
574
- const result = db.prepare(
575
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
576
- WHERE id=? AND updated_at=?`
577
- ).run(
578
- type,
579
- patch.title ?? existing.title,
580
- patch.content ?? existing.content,
581
- JSON.stringify(patch.tags ?? existing.tags),
582
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
583
- patch.source !== undefined ? patch.source : (existing.source ?? null),
584
- embedding,
585
- now,
586
- id,
587
- expectedUpdatedAt
588
- );
589
- if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
590
- // Only bump desired generation on a successful CAS — a miss writes nothing.
591
- runAtomically(() => { incrementGeneration(); });
591
+ // The CAS UPDATE and the desired-generation bump must commit together (audit
592
+ // peer A): if the UPDATE autocommits first and the process dies before the
593
+ // increment, the store is mutated while generation == applied_generation and
594
+ // dirty == false — recoverMirror sees no debt and the mirror stays stale.
595
+ // Wrapping both in one transaction means a CAS miss rolls back cleanly too
596
+ // (no write, no generation bump).
597
+ let applied = false;
598
+ runAtomically(() => {
599
+ const result = db.prepare(
600
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
601
+ WHERE id=? AND updated_at=?`
602
+ ).run(
603
+ type,
604
+ patch.title ?? existing.title,
605
+ patch.content ?? existing.content,
606
+ JSON.stringify(patch.tags ?? existing.tags),
607
+ Number.isInteger(patch.importance) ? patch.importance : existing.importance,
608
+ patch.source !== undefined ? patch.source : (existing.source ?? null),
609
+ embedding,
610
+ now,
611
+ id,
612
+ expectedUpdatedAt
613
+ );
614
+ if (result.changes === 0) return; // CAS miss: a concurrent write won
615
+ // Only bump desired generation on a successful CAS — a miss writes nothing.
616
+ incrementGeneration();
617
+ applied = true;
618
+ });
619
+ if (!applied) return undefined;
592
620
  return getById(id);
593
621
  }
594
622
 
@@ -610,46 +638,67 @@ export function createStore(path) {
610
638
  return getById(id);
611
639
  }
612
640
 
613
- /** Recall/inject touch. Records last_accessed_at WITHOUT bumping the mirror
614
- * generation sleep's "unrecalled N days demote/archive" tiering must not
615
- * spin the desired generation (that would mislead the mirror peer into
616
- * thinking the touched memory's content changed). Only bumps a timestamp,
617
- * so it is safe on hot recall paths. */
618
- function touchAccess(id) {
619
- const result = db.prepare(
620
- "UPDATE memories SET last_accessed_at = ? WHERE id = ?"
621
- ).run(nowIso(), id);
622
- return result.changes > 0;
623
- }
624
-
625
- /** Sleep demotion (v0.4.1): move the full body into _full_content and replace
626
- * content with a one-line summary. Skips when already demoted (_full_content
627
- * present) so a replayed sleep run never double-wraps. Bumps the mirror
628
- * generation because content visibly changes in the mirror file.
629
- *
630
- * minRefTimeMs (optional): the sleep tiering's freshness cutoff. A memory
631
- * whose reference time (last_accessed_at ?? updated_at ?? created_at) is
632
- * newer than the cutoff is skipped — phaseDemotion snapshots ref times up
633
- * front, and a recall touch landing between the snapshot and this call must
634
- * not demote a freshly-accessed memory. The check and the update run in the
635
- * same synchronous transaction, so the read-then-write is atomic. */
641
+ // --- sleep-mode storage support (v0.4.0) ---------------------------------
642
+ // touchLastAccess stamps the read time on recall/inject paths. It deliberately
643
+ // does NOT bump the mirror generation: reads must not mark the mirror dirty.
644
+ function touchLastAccess(id, at) {
645
+ if (!getById(id)) return false;
646
+ db.prepare("UPDATE memories SET last_accessed_at = ? WHERE id = ?")
647
+ .run(at ?? nowIso(), id);
648
+ return true;
649
+ }
650
+
651
+ // Shrink an aged memory to `summary`, parking its full body in _full_content.
652
+ // Idempotent: an already-demoted memory (non-null _full_content) is left
653
+ // untouched. minRefTimeMs guards the fast path if last_accessed_at moved
654
+ // after the caller's snapshot (>= minRefTimeMs), the memory is hot again and
655
+ // is skipped. Returns the updated memory, or undefined when skipped/absent.
636
656
  function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
657
+ let changed = false;
637
658
  runAtomically(() => {
638
- const row = db.prepare(
639
- "SELECT content, _full_content, last_accessed_at, updated_at, created_at FROM memories WHERE id = ?"
640
- ).get(id);
641
- if (!row || row._full_content) return; // idempotent: never double-wrap
642
- if (minRefTimeMs != null) {
643
- const ref = row.last_accessed_at ?? row.updated_at ?? row.created_at;
644
- const t = ref ? new Date(ref).getTime() : NaN;
645
- if (!Number.isNaN(t) && t >= minRefTimeMs) return; // freshly touched: keep full
659
+ const row = db.prepare("SELECT last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
660
+ if (!row || row._full_content) return;
661
+ if (minRefTimeMs !== undefined && row.last_accessed_at) {
662
+ const lastMs = Date.parse(row.last_accessed_at);
663
+ if (lastMs >= minRefTimeMs) return; // touched after snapshot — still hot
646
664
  }
647
665
  db.prepare(
648
- "UPDATE memories SET _full_content = ?, content = ?, updated_at = ? WHERE id = ?"
649
- ).run(row.content ?? "", summary ?? "", nowIso(), id);
666
+ "UPDATE memories SET content = ?, _full_content = ?, updated_at = ? WHERE id = ?"
667
+ ).run(summary, row.content, nowIso(), id);
650
668
  incrementGeneration();
669
+ changed = true;
651
670
  });
652
- return getById(id);
671
+ return changed ? getById(id) : undefined;
672
+ }
673
+
674
+ // Undo demoteToSummary: pull the parked body back into content.
675
+ function restoreContent(id) {
676
+ let changed = false;
677
+ runAtomically(() => {
678
+ const row = db.prepare("SELECT content, _full_content FROM memories WHERE id = ?").get(id);
679
+ if (!row || !row._full_content) return;
680
+ db.prepare(
681
+ "UPDATE memories SET content = ?, _full_content = NULL, updated_at = ? WHERE id = ?"
682
+ ).run(row._full_content, nowIso(), id);
683
+ incrementGeneration();
684
+ changed = true;
685
+ });
686
+ return changed ? getById(id) : undefined;
687
+ }
688
+
689
+ // Live memories that have not been touched since `cutMs` (never-touched ones
690
+ // fall back to created_at). Ordered by last access ascending — the coldest
691
+ // first. Used by sleep phase 2 to pick archival-demotion candidates.
692
+ function getUnrecalledSince(cutMs, { limit = 500 } = {}) {
693
+ const cutIso = new Date(cutMs).toISOString();
694
+ const rows = db.prepare(
695
+ `SELECT * FROM memories
696
+ WHERE forgotten = 0 AND archived = 0
697
+ AND (last_accessed_at IS NULL OR last_accessed_at < ?)
698
+ ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
699
+ LIMIT ?`
700
+ ).all(cutIso, limit);
701
+ return rows.map(toRow);
653
702
  }
654
703
 
655
704
  function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
@@ -1246,6 +1295,16 @@ export function createStore(path) {
1246
1295
  ).all(entityId, entityId).map(toRelation);
1247
1296
  }
1248
1297
 
1298
+ /** All entities (optionally name-filtered, newest first). Used by sleep phase 4
1299
+ * orphan detection: an entity with zero relations is a candidate for relation
1300
+ * completion. */
1301
+ function listEntities({ limit = 1000 } = {}) {
1302
+ const rows = db.prepare(
1303
+ "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
1304
+ ).all(limit);
1305
+ return rows.map(toEntity);
1306
+ }
1307
+
1249
1308
  // --- mirror sync state (F-NEW-03) -----------------------------------------
1250
1309
 
1251
1310
  /**
@@ -1286,8 +1345,15 @@ export function createStore(path) {
1286
1345
  if (key === "dirty") {
1287
1346
  value = value ? 1 : 0;
1288
1347
  } else if (key === "generation" || key === "applied_generation") {
1289
- value = Math.trunc(Number(value));
1290
- if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
1348
+ // Fail-closed integer enforcement (audit peer F): never truncate. A
1349
+ // fractional value like 1.5 previously passed the JS gate via
1350
+ // Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
1351
+ // dirty legacy row could carry a non-integer generation that reads as a
1352
+ // coherent applied round. Reject non-integers outright — the caller must
1353
+ // pass a whole number, and a stale dirty value stays visible instead of
1354
+ // being "repaired" into a misleading clean integer.
1355
+ value = Number(value);
1356
+ if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
1291
1357
  throw new RangeError(`mirror_state.${key} out of range: ${value}`);
1292
1358
  }
1293
1359
  } else if (key === "type_status" && value != null && typeof value !== "string") {
@@ -1437,8 +1503,10 @@ export function createStore(path) {
1437
1503
  remove,
1438
1504
  setForget,
1439
1505
  setArchived,
1440
- touchAccess,
1506
+ touchLastAccess,
1441
1507
  demoteToSummary,
1508
+ restoreContent,
1509
+ getUnrecalledSince,
1442
1510
  list,
1443
1511
  all,
1444
1512
  search,
@@ -1467,6 +1535,7 @@ export function createStore(path) {
1467
1535
  createEntity,
1468
1536
  findEntityByName,
1469
1537
  findEntityById,
1538
+ listEntities,
1470
1539
  updateEntity,
1471
1540
  saveAttr,
1472
1541
  invalidateOldAttr,