@modusensus/dsh-mneme 0.3.8 → 0.4.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/README.md +30 -3
- package/lib/config.js +28 -0
- package/lib/dream/decisions.js +79 -1
- package/lib/index.js +21 -0
- package/lib/service.js +55 -3
- package/lib/sleep.js +461 -0
- package/lib/store.js +72 -7
- package/package.json +1 -1
- package/src/config.js +28 -0
- package/src/dream/decisions.js +79 -1
- package/src/index.js +21 -0
- package/src/service.js +55 -3
- package/src/sleep.js +461 -0
- package/src/store.js +72 -7
- package/test/sleep.test.js +401 -0
package/src/dream/decisions.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
|
|
1
|
+
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
|
|
2
|
+
|
|
3
|
+
// create is used by sleep pattern discovery (v0.4.1). It fabricates a new
|
|
4
|
+
// memory of any known type (default pattern) rather than touching existing ids.
|
|
5
|
+
const CREATE_TYPES = new Set(["pattern", "preference", "project", "decision", "history", "summary"]);
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
@@ -9,6 +13,7 @@ const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
|
|
|
9
13
|
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
10
14
|
const errors = [];
|
|
11
15
|
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
16
|
+
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
12
17
|
const minAgeHours = options.minAgeHours ?? 24;
|
|
13
18
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
14
19
|
return { ok: false, errors: ["decision list must be a non-empty array"] };
|
|
@@ -20,6 +25,35 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
20
25
|
errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
21
26
|
continue;
|
|
22
27
|
}
|
|
28
|
+
// create claims no existing id: it fabricates a new memory, so it runs its
|
|
29
|
+
// own field validation and skips the ids-required check + the claimed set.
|
|
30
|
+
if (d.action === "create") {
|
|
31
|
+
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
32
|
+
errors.push(`${at}: create needs non-empty title`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
36
|
+
errors.push(`${at}: create needs non-empty content`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
40
|
+
errors.push(`${at}: create importance must be an integer 1-5 when provided`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (d.type !== undefined && (typeof d.type !== "string" || !CREATE_TYPES.has(d.type))) {
|
|
44
|
+
errors.push(`${at}: create type must be one of ${[...CREATE_TYPES].join(", ")}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (d.evidence !== undefined && !Array.isArray(d.evidence)) {
|
|
48
|
+
errors.push(`${at}: create evidence must be an array of memory ids`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (d.tags !== undefined && !Array.isArray(d.tags)) {
|
|
52
|
+
errors.push(`${at}: create tags must be an array`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
23
57
|
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
24
58
|
if (d.action === "conflict") {
|
|
25
59
|
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
@@ -95,6 +129,12 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
95
129
|
if (updateCount > maxUpdatePerRun) {
|
|
96
130
|
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
97
131
|
}
|
|
132
|
+
// Cap create churn: a pattern-discovery loop fabricating endless new memories
|
|
133
|
+
// would bloat the store, so a run can mint at most maxCreatePerRun.
|
|
134
|
+
const createCount = decisions.filter((d) => d.action === "create").length;
|
|
135
|
+
if (createCount > maxCreatePerRun) {
|
|
136
|
+
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
137
|
+
}
|
|
98
138
|
// Every snapshot id must appear in at least one decision
|
|
99
139
|
for (const id of snapshot.keys()) {
|
|
100
140
|
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
@@ -202,10 +242,48 @@ function applyOne(d, service, snapshot, config = {}) {
|
|
|
202
242
|
case "archive": return applyArchive(d, service, snapshot);
|
|
203
243
|
case "merge": return applyMerge(d, service, snapshot, config);
|
|
204
244
|
case "conflict": return applyConflict(d, service, snapshot);
|
|
245
|
+
case "create": return applyCreate(d, service, snapshot);
|
|
205
246
|
default: return applyUpdate(d, service, snapshot, config);
|
|
206
247
|
}
|
|
207
248
|
}
|
|
208
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Mint a new memory (sleep pattern discovery). saveWithDedupe dedupes by
|
|
252
|
+
* (type, title) so a replayed create merges instead of duplicating — the
|
|
253
|
+
* idempotency guard. Evidence ids are folded into tags as `ev:<id>` so a
|
|
254
|
+
* pattern's provenance stays queryable after creation.
|
|
255
|
+
*/
|
|
256
|
+
function applyCreate(d, service, snapshot) {
|
|
257
|
+
const evidence = Array.isArray(d.evidence) ? d.evidence : [];
|
|
258
|
+
const tags = [
|
|
259
|
+
...(Array.isArray(d.tags) ? d.tags : []),
|
|
260
|
+
...evidence.map((id) => `ev:${id}`)
|
|
261
|
+
];
|
|
262
|
+
const result = service.saveWithDedupe({
|
|
263
|
+
type: d.type ?? "pattern",
|
|
264
|
+
title: d.title,
|
|
265
|
+
content: d.content,
|
|
266
|
+
importance: d.importance ?? 3,
|
|
267
|
+
tags,
|
|
268
|
+
source: "dream-create"
|
|
269
|
+
});
|
|
270
|
+
if (!result?.memory) return "skipped";
|
|
271
|
+
return {
|
|
272
|
+
applied: 1,
|
|
273
|
+
committed: {
|
|
274
|
+
action: "create",
|
|
275
|
+
id: result.memory.id,
|
|
276
|
+
type: d.type ?? "pattern",
|
|
277
|
+
title: d.title,
|
|
278
|
+
content: d.content,
|
|
279
|
+
importance: d.importance ?? 3,
|
|
280
|
+
evidence,
|
|
281
|
+
count_before: 0,
|
|
282
|
+
count_after: 1
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
209
287
|
function applyArchive(d, service, snapshot) {
|
|
210
288
|
const targets = d.ids.filter((id) => {
|
|
211
289
|
const mem = service.getById(id);
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createTools } from "./tools.js";
|
|
|
5
5
|
import { createInjector } from "./inject.js";
|
|
6
6
|
import { createSummarizer } from "./summarize.js";
|
|
7
7
|
import { createDreamScheduler } from "./dream.js";
|
|
8
|
+
import { createSleepScheduler, runSleep } from "./sleep.js";
|
|
8
9
|
import { createApi } from "./api.js";
|
|
9
10
|
import { createSettings } from "./settings.js";
|
|
10
11
|
import { createCommandManager } from "./commands.js";
|
|
@@ -181,6 +182,25 @@ export const apply = (ctx, config) => {
|
|
|
181
182
|
service.setDreamHook(() => dream.maybeSchedule(service));
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Sleep scheduler (v0.4.1): idle-triggered deep pass (conflict resolution +
|
|
186
|
+
// archival demotion + pattern discovery). opt-in via sleepEnabled; writes
|
|
187
|
+
// through the service reset the idle clock (setSleepHook), and when the store
|
|
188
|
+
// stays quiet for sleepIdleMinutes past the sleepMinIntervalHours gate, the
|
|
189
|
+
// scheduler fires one cycle. The onRun closure reuses the same semantic
|
|
190
|
+
// pipeline as dream for the conflict phase.
|
|
191
|
+
let sleep = null;
|
|
192
|
+
if (cfg.sleepEnabled) {
|
|
193
|
+
sleep = createSleepScheduler({
|
|
194
|
+
service,
|
|
195
|
+
config: cfg,
|
|
196
|
+
logger: ctx.logger,
|
|
197
|
+
onRun: () => (sleep
|
|
198
|
+
? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex })
|
|
199
|
+
: Promise.resolve({ ok: true, skipped: true }))
|
|
200
|
+
});
|
|
201
|
+
service.setSleepHook(() => sleep.noteWrite());
|
|
202
|
+
}
|
|
203
|
+
|
|
184
204
|
// Entity gene extraction (v0.3.0): wire the extractor into the service as a
|
|
185
205
|
// hook so saveWithDedupe can fire-and-forget an extraction pass on fresh
|
|
186
206
|
// writes. The service never sees ctx.llm — index.js adapts it here into the
|
|
@@ -251,6 +271,7 @@ export const apply = (ctx, config) => {
|
|
|
251
271
|
}
|
|
252
272
|
commands?.dispose();
|
|
253
273
|
if (dream) await dream.dispose();
|
|
274
|
+
if (sleep) await sleep.dispose();
|
|
254
275
|
store.close();
|
|
255
276
|
};
|
|
256
277
|
};
|
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.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.
|
|
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.1). 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"
|
|
@@ -243,11 +261,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
243
261
|
// entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
|
|
244
262
|
if (config?.entitySearchEnabled) {
|
|
245
263
|
if (q.startsWith("entity:")) {
|
|
246
|
-
|
|
264
|
+
const hits = searchByEntity(q.slice(7).trim(), options);
|
|
265
|
+
touchRecalled(hits);
|
|
266
|
+
return hits;
|
|
247
267
|
}
|
|
248
268
|
if (q.startsWith("attr:")) {
|
|
249
269
|
const [key, value] = q.slice(5).split("=");
|
|
250
|
-
|
|
270
|
+
const hits = searchByAttr(key, value, options);
|
|
271
|
+
touchRecalled(hits);
|
|
272
|
+
return hits;
|
|
251
273
|
}
|
|
252
274
|
}
|
|
253
275
|
|
|
@@ -345,6 +367,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
345
367
|
});
|
|
346
368
|
} catch { /* recall receipt is best effort */ }
|
|
347
369
|
}
|
|
370
|
+
touchRecalled(result);
|
|
348
371
|
return result;
|
|
349
372
|
}
|
|
350
373
|
|
|
@@ -362,6 +385,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
362
385
|
if (dreamHook) {
|
|
363
386
|
try { dreamHook(); } catch { /* ignore */ }
|
|
364
387
|
}
|
|
388
|
+
if (sleepHook) {
|
|
389
|
+
try { sleepHook(); } catch { /* ignore */ }
|
|
390
|
+
}
|
|
365
391
|
}
|
|
366
392
|
|
|
367
393
|
/**
|
|
@@ -430,6 +456,23 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
430
456
|
return { action: "created", memory: created };
|
|
431
457
|
}
|
|
432
458
|
|
|
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
|
+
|
|
433
476
|
/**
|
|
434
477
|
* Candidate memories for automatic context injection:
|
|
435
478
|
* summaries first, then all preferences, then non-forgotten items with
|
|
@@ -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
|
/**
|
|
@@ -758,7 +803,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
758
803
|
mergeHumanEdits,
|
|
759
804
|
toApiList,
|
|
760
805
|
transaction,
|
|
806
|
+
enqueue,
|
|
761
807
|
setDreamHook(fn) { dreamHook = fn; },
|
|
808
|
+
setSleepHook(fn) { sleepHook = fn; },
|
|
762
809
|
setEmbedder(emb) {
|
|
763
810
|
embedder = emb;
|
|
764
811
|
if (!emb) {
|
|
@@ -870,6 +917,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
870
917
|
afterSync("write");
|
|
871
918
|
return updated;
|
|
872
919
|
},
|
|
920
|
+
demoteToSummary: (id, summary, opts) => {
|
|
921
|
+
const updated = store.demoteToSummary(id, summary, opts);
|
|
922
|
+
afterSync("write");
|
|
923
|
+
return updated;
|
|
924
|
+
},
|
|
873
925
|
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|
|
874
926
|
// an audit write is bookkeeping, and notifyWrite would loop back into the
|
|
875
927
|
// dream scheduler that just recorded the run.
|