@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.
@@ -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
- return searchByEntity(q.slice(7).trim(), options);
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
- return searchByAttr(key, value, options);
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
- return items.slice(0, maxItems);
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.