@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/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,13 +183,13 @@ 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
  `;
188
191
 
189
- const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
192
+ const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
190
193
 
191
194
  // Per-type mirror sync receipts (peer blocker 4): a type is either committed
192
195
  // (file written + fence applied), failed (last sync round errored for it), or
@@ -227,7 +230,9 @@ function toRow(row) {
227
230
  archived: row.archived === 1,
228
231
  source: row.source ?? undefined,
229
232
  created_at: row.created_at,
230
- updated_at: row.updated_at
233
+ updated_at: row.updated_at,
234
+ last_accessed_at: row.last_accessed_at ?? undefined,
235
+ _full_content: row._full_content ?? undefined
231
236
  };
232
237
  }
233
238
 
@@ -248,7 +253,8 @@ function toDreamRun(row) {
248
253
  applied: row.applied,
249
254
  summary_stored: row.summary_stored === 1,
250
255
  receipt: row.receipt,
251
- policy_epoch: row.policy_epoch ?? 0
256
+ policy_epoch: row.policy_epoch ?? 0,
257
+ run_type: row.run_type ?? "auto"
252
258
  };
253
259
  }
254
260
 
@@ -391,11 +397,14 @@ function parseJsonArray(raw) {
391
397
 
392
398
  export function createStore(path) {
393
399
  const db = new DatabaseSync(path);
394
- db.exec("PRAGMA journal_mode = WAL;");
395
- // Concurrent writers (peer probe: 8 independent processes) must wait for the
396
- // write lock instead of failing immediately with SQLITE_BUSY otherwise the
397
- // 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.
398
406
  db.exec("PRAGMA busy_timeout = 5000;");
407
+ db.exec("PRAGMA journal_mode = WAL;");
399
408
  db.exec(SCHEMA);
400
409
 
401
410
  // Schema migrations for legacy databases (idempotent).
@@ -406,12 +415,21 @@ export function createStore(path) {
406
415
  if (!columns.includes("embedding")) {
407
416
  db.exec("ALTER TABLE memories ADD COLUMN embedding TEXT");
408
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
+ }
409
424
 
410
425
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
411
426
  const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
412
427
  if (!dreamCols.includes("policy_epoch")) {
413
428
  db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
414
429
  }
430
+ if (!dreamCols.includes("run_type")) {
431
+ db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
432
+ }
415
433
 
416
434
  // Legacy mirror_state without v0.3.6 generation columns → add each missing
417
435
  // column idempotently (old DBs open cleanly, no data loss).
@@ -426,6 +444,24 @@ export function createStore(path) {
426
444
  db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
427
445
  }
428
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
+
429
465
  // Per-instance monotonic timestamp guard: consecutive writes within the same
430
466
  // millisecond must still produce strictly increasing timestamps (test asserts
431
467
  // updated_at != created_at). State lives in the store closure, not module scope.
@@ -552,24 +588,35 @@ export function createStore(path) {
552
588
  const embedding = patch.embedding !== undefined
553
589
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
554
590
  : existing.embedding ?? null;
555
- const result = db.prepare(
556
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
557
- WHERE id=? AND updated_at=?`
558
- ).run(
559
- type,
560
- patch.title ?? existing.title,
561
- patch.content ?? existing.content,
562
- JSON.stringify(patch.tags ?? existing.tags),
563
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
564
- patch.source !== undefined ? patch.source : (existing.source ?? null),
565
- embedding,
566
- now,
567
- id,
568
- expectedUpdatedAt
569
- );
570
- if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
571
- // Only bump desired generation on a successful CAS — a miss writes nothing.
572
- 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;
573
620
  return getById(id);
574
621
  }
575
622
 
@@ -591,6 +638,69 @@ export function createStore(path) {
591
638
  return getById(id);
592
639
  }
593
640
 
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.
656
+ function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
657
+ let changed = false;
658
+ runAtomically(() => {
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
664
+ }
665
+ db.prepare(
666
+ "UPDATE memories SET content = ?, _full_content = ?, updated_at = ? WHERE id = ?"
667
+ ).run(summary, row.content, nowIso(), id);
668
+ incrementGeneration();
669
+ changed = true;
670
+ });
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);
702
+ }
703
+
594
704
  function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
595
705
  const clauses = [];
596
706
  const params = [];
@@ -716,16 +826,17 @@ export function createStore(path) {
716
826
  const id = run.id ?? randomUUID();
717
827
  const now = nowIso();
718
828
  const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
829
+ const runType = run.run_type ?? "auto";
719
830
  db.prepare(
720
831
  `INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
721
- input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch)
722
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
832
+ input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
833
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
723
834
  ON CONFLICT(id) DO UPDATE SET
724
835
  created_at=excluded.created_at, status=excluded.status, error=excluded.error,
725
836
  provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
726
837
  input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
727
838
  outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
728
- receipt=excluded.receipt, policy_epoch=excluded.policy_epoch`
839
+ receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
729
840
  ).run(
730
841
  id,
731
842
  run.created_at ?? now,
@@ -741,7 +852,8 @@ export function createStore(path) {
741
852
  run.applied ?? 0,
742
853
  run.summary_stored ? 1 : 0,
743
854
  run.receipt,
744
- policyEpoch
855
+ policyEpoch,
856
+ runType
745
857
  );
746
858
  return getDreamRun(id);
747
859
  }
@@ -1183,6 +1295,16 @@ export function createStore(path) {
1183
1295
  ).all(entityId, entityId).map(toRelation);
1184
1296
  }
1185
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
+
1186
1308
  // --- mirror sync state (F-NEW-03) -----------------------------------------
1187
1309
 
1188
1310
  /**
@@ -1223,8 +1345,15 @@ export function createStore(path) {
1223
1345
  if (key === "dirty") {
1224
1346
  value = value ? 1 : 0;
1225
1347
  } else if (key === "generation" || key === "applied_generation") {
1226
- value = Math.trunc(Number(value));
1227
- 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) {
1228
1357
  throw new RangeError(`mirror_state.${key} out of range: ${value}`);
1229
1358
  }
1230
1359
  } else if (key === "type_status" && value != null && typeof value !== "string") {
@@ -1374,6 +1503,10 @@ export function createStore(path) {
1374
1503
  remove,
1375
1504
  setForget,
1376
1505
  setArchived,
1506
+ touchLastAccess,
1507
+ demoteToSummary,
1508
+ restoreContent,
1509
+ getUnrecalledSince,
1377
1510
  list,
1378
1511
  all,
1379
1512
  search,
@@ -1402,6 +1535,7 @@ export function createStore(path) {
1402
1535
  createEntity,
1403
1536
  findEntityByName,
1404
1537
  findEntityById,
1538
+ listEntities,
1405
1539
  updateEntity,
1406
1540
  saveAttr,
1407
1541
  invalidateOldAttr,
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { EventEmitter } from "node:events";
@@ -464,3 +464,36 @@ test("V0.3.6-F1: 旧库(v0.3.5 5 列)打开自动 ALTER 加 3 列,不丢
464
464
  rmSync(dir, { recursive: true, force: true });
465
465
  }
466
466
  });
467
+
468
+ test("v0.3.9-D: mirror 逐 type 物理终态——兄弟 type 失败不误标已提交 type", () => {
469
+ const { dir, store, mirror, service } = setup();
470
+ try {
471
+ // 注入逐 type 故障:project 写成功、decision 抛错(模拟 EISDIR),其余 type 正常。
472
+ // 对应审计 D:project 文件已物理提交、decision 失败,状态必须逐 type 记录——
473
+ // 不能像旧逻辑那样整体批量标 failed。
474
+ const original = mirror.sync.bind(mirror);
475
+ mirror.sync = (memories) => {
476
+ const results = original(memories);
477
+ results.decision = { ok: false, error: "EISDIR: decision.md is a directory" };
478
+ return results;
479
+ };
480
+ // project 与 decision 都有真实记忆 → 触发逐 type 渲染
481
+ store.save({ id: "mem-project", type: "project", title: "已提交", content: "物理写入", importance: 3, tags: [] });
482
+ store.save({ id: "mem-decision", type: "decision", title: "写失败", content: "此 type 失败", importance: 3, tags: [] });
483
+ // 直接调 service 内部 syncMirror(通过一次写触发)
484
+ service.saveWithDedupe({ type: "project", title: "触发", content: "sync", importance: 3 });
485
+
486
+ const ts = store.getTypeStatus();
487
+ // project 物理提交 → 必须 committed,不能被 decision 失败拖成 failed
488
+ assert.equal(ts.project.status, "committed", "物理已提交的 type 必须标记 committed");
489
+ assert.equal(ts.decision.status, "failed", "失败的 type 必须标记 failed");
490
+ // 部分失败 = 未完全收敛 → dirty 必须持久
491
+ assert.equal(store.getMirrorState().dirty, true, "部分 type 失败必须持久 dirty");
492
+ // 镜像里 project 文件真实存在(物理终态已落地,不是整体失败)
493
+ const projectFile = mirror.filePath("project");
494
+ assert.ok(existsSync(projectFile), "project 镜像文件必须已物理写入");
495
+ } finally {
496
+ store.close();
497
+ rmSync(dir, { recursive: true, force: true });
498
+ }
499
+ });
@@ -146,3 +146,45 @@ test("peer-D: generation 上界与负数拒绝", () => {
146
146
  rmSync(dir, { recursive: true, force: true });
147
147
  }
148
148
  });
149
+
150
+ test("v0.3.9-A: compareAndUpdate 的 CAS UPDATE 与 generation 同事务——miss 不得递增", () => {
151
+ const { dir, store } = setup();
152
+ try {
153
+ const saved = store.save({ type: "project", title: "CAS 原子", content: "v0" });
154
+ const before = store.getById(saved.id);
155
+ const genBefore = store.getMirrorState().generation;
156
+
157
+ // 成功 CAS:业务写入 + generation 递增必须一次提交(同事务)
158
+ const updated = store.compareAndUpdate(saved.id, before.updated_at, { content: "v1" });
159
+ assert.ok(updated, "当前版本 CAS 必须成功");
160
+ const genAfterOk = store.getMirrorState().generation;
161
+ assert.equal(genAfterOk, genBefore + 1, "成功 CAS 必须恰好递增一次 generation");
162
+
163
+ // miss CAS:不写任何东西,generation 也不得递增
164
+ const stale = store.getById(saved.id).updated_at; // v1 的 token
165
+ store.compareAndUpdate(saved.id, before.updated_at, { content: "v2" }); // 用旧 token → miss
166
+ assert.equal(store.getById(saved.id).content, "v1", "miss 不得改数据");
167
+ assert.equal(store.getMirrorState().generation, genAfterOk,
168
+ "CAS miss 不得递增 generation(UPDATE 与 increment 必须同事务)");
169
+ } finally {
170
+ store.close();
171
+ rmSync(dir, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ test("v0.3.9-F: generation 非整数必须拒绝(不得截断)", () => {
176
+ const { dir, store } = setup();
177
+ try {
178
+ // 审计 peer F:1.5 这类小数此前被 Math.trunc 截断 + SQLite CHECK 接受 → 静默脏值。
179
+ // fail-closed:JS 与 SQL 统一只接受整数。
180
+ assert.throws(() => store.setMirrorState({ generation: 1.5 }), RangeError, "小数 generation 必须拒绝");
181
+ assert.throws(() => store.setMirrorState({ applied_generation: -1.5 }), RangeError, "负数小数必须拒绝");
182
+ assert.throws(() => store.setMirrorState({ generation: Number.MAX_SAFE_INTEGER + 0.5 }), RangeError, "超界小数必须拒绝");
183
+ // 整数仍正常
184
+ const s = store.setMirrorState({ generation: 7 });
185
+ assert.equal(s.generation, 7, "整数 generation 正常");
186
+ } finally {
187
+ store.close();
188
+ rmSync(dir, { recursive: true, force: true });
189
+ }
190
+ });