@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/README.md +49 -3
- package/lib/api.js +1 -1
- 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/mirror.js +24 -12
- package/lib/service.js +141 -23
- package/lib/store.js +168 -34
- package/package.json +2 -2
- package/scripts/e2e-dsh.js +4 -2
- package/src/api.js +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/mirror.js +24 -12
- package/src/service.js +141 -23
- package/src/store.js +168 -34
- package/test/mirror-generation.test.js +34 -1
- package/test/peer-blockers.test.js +42 -0
- package/test/sleep.test.js +365 -0
package/lib/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
|
/**
|
|
@@ -603,26 +648,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
603
648
|
}
|
|
604
649
|
}
|
|
605
650
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
+
}
|
|
616
673
|
}
|
|
617
|
-
|
|
618
|
-
|
|
674
|
+
|
|
675
|
+
// 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
|
|
676
|
+
// 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
677
|
+
if (allOk) {
|
|
619
678
|
try {
|
|
620
|
-
store.
|
|
679
|
+
store.markMirrorCleanForGeneration(gen, now);
|
|
621
680
|
} catch (stateError) {
|
|
622
|
-
logger?.warn?.(
|
|
681
|
+
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
682
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
623
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);
|
|
624
696
|
}
|
|
625
|
-
return { success:
|
|
697
|
+
return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
|
|
626
698
|
} catch (error) {
|
|
627
699
|
const errMsg = error?.message ?? String(error);
|
|
628
700
|
logger?.warn?.("syncMirror failed:", error);
|
|
@@ -646,14 +718,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
646
718
|
}
|
|
647
719
|
|
|
648
720
|
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
649
|
-
// swallowing it (peer blocker 2). The mirror debt has already
|
|
650
|
-
// by markMirrorDirty inside syncMirror, so a restart recovers —
|
|
651
|
-
// calling write path must not report clean while the mirror is
|
|
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.
|
|
652
726
|
function afterSync(label) {
|
|
653
727
|
const r = syncMirror();
|
|
654
728
|
if (!r?.success && !r?.deferred) {
|
|
655
729
|
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
656
730
|
}
|
|
731
|
+
return r;
|
|
657
732
|
}
|
|
658
733
|
|
|
659
734
|
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
@@ -758,7 +833,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
758
833
|
mergeHumanEdits,
|
|
759
834
|
toApiList,
|
|
760
835
|
transaction,
|
|
836
|
+
enqueue,
|
|
761
837
|
setDreamHook(fn) { dreamHook = fn; },
|
|
838
|
+
setSleepHook(fn) { sleepHook = fn; },
|
|
762
839
|
setEmbedder(emb) {
|
|
763
840
|
embedder = emb;
|
|
764
841
|
if (!emb) {
|
|
@@ -826,9 +903,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
826
903
|
memory_id: id
|
|
827
904
|
});
|
|
828
905
|
}
|
|
829
|
-
afterSync("write");
|
|
906
|
+
const sync = afterSync("write");
|
|
830
907
|
notifyWrite();
|
|
831
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
|
+
}
|
|
832
920
|
return updated;
|
|
833
921
|
},
|
|
834
922
|
// Compare-and-set update: applies the patch only when the row still carries
|
|
@@ -855,9 +943,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
855
943
|
memory_id: id
|
|
856
944
|
});
|
|
857
945
|
}
|
|
858
|
-
afterSync("write");
|
|
946
|
+
const sync = afterSync("write");
|
|
859
947
|
notifyWrite();
|
|
860
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
|
+
}
|
|
861
957
|
return updated;
|
|
862
958
|
},
|
|
863
959
|
setForget: (id, f) => {
|
|
@@ -870,6 +966,22 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
870
966
|
afterSync("write");
|
|
871
967
|
return updated;
|
|
872
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),
|
|
873
985
|
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|
|
874
986
|
// an audit write is bookkeeping, and notifyWrite would loop back into the
|
|
875
987
|
// dream scheduler that just recorded the run.
|
|
@@ -892,6 +1004,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
892
1004
|
// migrates entity_attrs on merge. Bookkeeping writes like the audit
|
|
893
1005
|
// passthroughs above — never write-hook-triggering memory mutations.
|
|
894
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),
|
|
895
1013
|
getAttrsByMemory: (id) => store.getAttrsByMemory(id),
|
|
896
1014
|
migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
|
|
897
1015
|
};
|
package/lib/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
|
-
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
|
-
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors,
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/scripts/e2e-dsh.js
CHANGED
|
@@ -104,9 +104,11 @@ console.log(`记忆目录:${memDir}\n`);
|
|
|
104
104
|
// 1. 装载检查
|
|
105
105
|
console.log("【1】插件装载");
|
|
106
106
|
const checks = [];
|
|
107
|
-
checks.push(["注册
|
|
107
|
+
checks.push(["注册 7 个模型工具", registeredTools.length === 7]);
|
|
108
108
|
checks.push(["注册 2 个注入上下文", injectContexts.length === 2 && injectContexts[0].name === "memory"]);
|
|
109
|
-
|
|
109
|
+
// 契约是 9 条 exact 路由;prefix fallback(/api/dsh-mneme → 404)是兜底,
|
|
110
|
+
// 不计入路由数。
|
|
111
|
+
checks.push(["注册 9 条 API 路由", apiRoutes.filter((r) => r.kind === "exact").length === 9]);
|
|
110
112
|
for (const [label, ok] of checks) console.log(` ${ok ? "✅" : "❌"} ${label}`);
|
|
111
113
|
if (!checks.every(([, ok]) => ok)) { console.log("\n装载检查失败,中止。"); process.exit(1); }
|
|
112
114
|
console.log(` 工具:${registeredTools.map((t) => t.name).join(", ")}\n`);
|