@modusensus/dsh-mneme 0.3.9 → 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/service.js CHANGED
@@ -9,6 +9,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
9
9
  // passed in the constructor). Fired on the same write events as onWrite.
10
10
  let dreamHook = null;
11
11
 
12
+ // Optional sleep scheduler hook (v0.4.0), installed via setSleepHook after
13
+ // creation. Fired on the same write events as onWrite: it tells the sleep
14
+ // scheduler the store just changed so the idle-detection clock resets.
15
+ let sleepHook = null;
16
+
12
17
  // Optional vector embedder, installed via setEmbedder after creation. After
13
18
  // any content write it fire-and-forgets a re-embed of the row so vector
14
19
  // search stays in sync; failures are swallowed inside the embedder.
@@ -37,6 +42,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
37
42
  // replays them exactly once against the committed state.
38
43
  let txDepth = 0;
39
44
 
45
+ // Serial task queue (sleep v0.4.0). Long-running background passes — dream
46
+ // consolidation, sleep cycles — must never overlap: two sleep runs racing
47
+ // would double-demote or double-mint patterns. enqueue chains the task onto
48
+ // a promise tail so N callers can queue work that runs strictly one at a
49
+ // time. A task that rejects doesn't poison the queue (the tail swallows the
50
+ // rejection) but the rejection still propagates to that caller.
51
+ let queueTail = Promise.resolve();
52
+ function enqueue(fn) {
53
+ const next = queueTail.then(fn, fn);
54
+ queueTail = next.catch(() => {});
55
+ return next;
56
+ }
57
+
40
58
  // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
41
59
  // Ollama) exposes an async init(), so between `setEmbedder` and init()
42
60
  // resolving there is a window where embedSingle would throw "not initialized"
@@ -187,7 +205,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
187
205
  for (const mem of keywordHits) {
188
206
  if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
189
207
  }
190
- return Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
208
+ const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
209
+ touchRecalled(hits);
210
+ return hits;
191
211
  }
192
212
 
193
213
  /**
@@ -205,7 +225,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
205
225
  // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
206
226
  // 当前有效记忆(v0.3.0,store.js 已实现)。
207
227
  const rows = store.findMemoriesByAttr(key, value ?? "");
208
- return rows.slice(0, topK);
228
+ const hits = rows.slice(0, topK);
229
+ touchRecalled(hits);
230
+ return hits;
209
231
  }
210
232
 
211
233
  /**
@@ -235,6 +257,23 @@ export function createService({ store, mirror, config, onWrite, logger }) {
235
257
  return base * (0.5 + (row.importance ?? 3) / 10);
236
258
  }
237
259
 
260
+ /**
261
+ * Sleep touch (v0.4.0): when sleep is enabled, any memory surfaced by recall
262
+ * or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
263
+ * days → demote/archive" tiering counts real access. Best-effort and gated on
264
+ * config.sleepModeEnabled — when sleep is off this is a complete no-op (no
265
+ * writes on the hot recall path). A touch failure must never break search/inject.
266
+ */
267
+ function touchRecalled(memories) {
268
+ if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
269
+ for (const m of memories) {
270
+ if (!m?.id) continue;
271
+ try {
272
+ store.touchLastAccess(m.id);
273
+ } catch { /* touch is best effort */ }
274
+ }
275
+ }
276
+
238
277
  async function searchMemories(query, options = {}) {
239
278
  const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
240
279
  const q = String(query ?? "").trim();
@@ -345,6 +384,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
345
384
  });
346
385
  } catch { /* recall receipt is best effort */ }
347
386
  }
387
+ touchRecalled(result);
348
388
  return result;
349
389
  }
350
390
 
@@ -362,6 +402,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
362
402
  if (dreamHook) {
363
403
  try { dreamHook(); } catch { /* ignore */ }
364
404
  }
405
+ if (sleepHook) {
406
+ try { sleepHook(); } catch { /* ignore */ }
407
+ }
365
408
  }
366
409
 
367
410
  /**
@@ -446,7 +489,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
446
489
  const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
447
490
  return pa - pb || b.importance - a.importance;
448
491
  });
449
- return items.slice(0, maxItems);
492
+ const selected = items.slice(0, maxItems);
493
+ touchRecalled(selected);
494
+ return selected;
450
495
  }
451
496
 
452
497
  /**
@@ -788,7 +833,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
788
833
  mergeHumanEdits,
789
834
  toApiList,
790
835
  transaction,
836
+ enqueue,
791
837
  setDreamHook(fn) { dreamHook = fn; },
838
+ setSleepHook(fn) { sleepHook = fn; },
792
839
  setEmbedder(emb) {
793
840
  embedder = emb;
794
841
  if (!emb) {
@@ -919,6 +966,22 @@ export function createService({ store, mirror, config, onWrite, logger }) {
919
966
  afterSync("write");
920
967
  return updated;
921
968
  },
969
+ // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
970
+ // content so they ride the normal write-hook path (mirror re-renders).
971
+ // touchLastAccess is a read-stamp — deliberately NO write hook (a recall
972
+ // must not dirty the mirror). getUnrecalledSince is a pure read.
973
+ demoteToSummary: (id, summary, opts) => {
974
+ const updated = store.demoteToSummary(id, summary, opts);
975
+ afterSync("write");
976
+ return updated;
977
+ },
978
+ restoreContent: (id) => {
979
+ const updated = store.restoreContent(id);
980
+ afterSync("write");
981
+ return updated;
982
+ },
983
+ touchLastAccess: (id, at) => store.touchLastAccess(id, at),
984
+ getUnrecalledSince: (cutMs, opts) => store.getUnrecalledSince(cutMs, opts),
922
985
  // autoDream audit trail: passthroughs deliberately bypass write hooks —
923
986
  // an audit write is bookkeeping, and notifyWrite would loop back into the
924
987
  // dream scheduler that just recorded the run.
@@ -941,6 +1004,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
941
1004
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
942
1005
  // passthroughs above — never write-hook-triggering memory mutations.
943
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),
944
1013
  getAttrsByMemory: (id) => store.getAttrsByMemory(id),
945
1014
  migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
946
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
 
@@ -186,7 +189,7 @@ CREATE TABLE IF NOT EXISTS mirror_state (
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
 
@@ -409,12 +415,21 @@ export function createStore(path) {
409
415
  if (!columns.includes("embedding")) {
410
416
  db.exec("ALTER TABLE memories ADD COLUMN embedding TEXT");
411
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
+ }
412
424
 
413
425
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
414
426
  const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
415
427
  if (!dreamCols.includes("policy_epoch")) {
416
428
  db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
417
429
  }
430
+ if (!dreamCols.includes("run_type")) {
431
+ db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
432
+ }
418
433
 
419
434
  // Legacy mirror_state without v0.3.6 generation columns → add each missing
420
435
  // column idempotently (old DBs open cleanly, no data loss).
@@ -623,6 +638,69 @@ export function createStore(path) {
623
638
  return getById(id);
624
639
  }
625
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
+
626
704
  function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
627
705
  const clauses = [];
628
706
  const params = [];
@@ -748,16 +826,17 @@ export function createStore(path) {
748
826
  const id = run.id ?? randomUUID();
749
827
  const now = nowIso();
750
828
  const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
829
+ const runType = run.run_type ?? "auto";
751
830
  db.prepare(
752
831
  `INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
753
- input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch)
754
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
832
+ input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
833
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
755
834
  ON CONFLICT(id) DO UPDATE SET
756
835
  created_at=excluded.created_at, status=excluded.status, error=excluded.error,
757
836
  provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
758
837
  input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
759
838
  outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
760
- receipt=excluded.receipt, policy_epoch=excluded.policy_epoch`
839
+ receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
761
840
  ).run(
762
841
  id,
763
842
  run.created_at ?? now,
@@ -773,7 +852,8 @@ export function createStore(path) {
773
852
  run.applied ?? 0,
774
853
  run.summary_stored ? 1 : 0,
775
854
  run.receipt,
776
- policyEpoch
855
+ policyEpoch,
856
+ runType
777
857
  );
778
858
  return getDreamRun(id);
779
859
  }
@@ -1215,6 +1295,16 @@ export function createStore(path) {
1215
1295
  ).all(entityId, entityId).map(toRelation);
1216
1296
  }
1217
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
+
1218
1308
  // --- mirror sync state (F-NEW-03) -----------------------------------------
1219
1309
 
1220
1310
  /**
@@ -1413,6 +1503,10 @@ export function createStore(path) {
1413
1503
  remove,
1414
1504
  setForget,
1415
1505
  setArchived,
1506
+ touchLastAccess,
1507
+ demoteToSummary,
1508
+ restoreContent,
1509
+ getUnrecalledSince,
1416
1510
  list,
1417
1511
  all,
1418
1512
  search,
@@ -1441,6 +1535,7 @@ export function createStore(path) {
1441
1535
  createEntity,
1442
1536
  findEntityByName,
1443
1537
  findEntityById,
1538
+ listEntities,
1444
1539
  updateEntity,
1445
1540
  saveAttr,
1446
1541
  invalidateOldAttr,