@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/README.md +20 -2
- package/lib/config.js +38 -0
- package/lib/dream/decisions.js +49 -1
- package/lib/dream/sleep.js +550 -0
- package/lib/index.js +18 -0
- package/lib/service.js +72 -3
- package/lib/store.js +103 -8
- package/package.json +1 -1
- package/src/config.js +38 -0
- package/src/dream/decisions.js +49 -1
- package/src/dream/sleep.js +550 -0
- package/src/index.js +18 -0
- package/src/service.js +72 -3
- package/src/store.js +103 -8
- package/test/sleep.test.js +365 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|