@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/lib/sleep.js ADDED
@@ -0,0 +1,461 @@
1
+ // System-level sleep (v0.4.1): an idle-triggered, LLM-assisted deep pass over
2
+ // the memory store. Three independent, fail-safe phases:
3
+ // 1. conflict resolution — high-similarity same-type pairs are either parked
4
+ // for review (freeze mode) or adjudicated by the LLM (winner kept / loser
5
+ // archived), reusing the dream conflict machinery.
6
+ // 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
7
+ // to a one-line summary with the full body moved to _full_content; past
8
+ // sleepDeepArchiveDays they are archived outright.
9
+ // 3. pattern discovery — the LLM scans the most recent memories and mints
10
+ // type=pattern entries carrying evidence id references.
11
+ // Each phase is wrapped so one failure never aborts the others, and a missing
12
+ // LLM route / semantic embedder only skips the phases that need it.
13
+ import { randomUUID, createHash } from "node:crypto";
14
+ import { validateDecisions, applyDecisions } from "./dream/decisions.js";
15
+ import { findPotentialConflicts } from "./dream/clustering.js";
16
+ import { buildReceipt } from "./dream.js";
17
+
18
+ const SUMMARY_MAX = 120;
19
+ const CONFLICT_THRESHOLD = 0.85;
20
+
21
+ const CONFLICT_PROMPT = `你是记忆库冲突仲裁助手。下面是检测到的高相似度记忆对,可能内容矛盾或重复。
22
+ 对每一对输出一个 decision 对象:
23
+ - 两条确实矛盾/重复 → { "action": "conflict", "winner": <保留的id>, "loser": <归档的id>, "reason": "理由" }
24
+ - 两条只是主题相近、并无矛盾 → { "action": "keep", "ids": [<两个id>] }
25
+ 规则:
26
+ - winner 应为信息更完整、更新或更可信的一条
27
+ - 只使用提供的 id,不要编造
28
+ - 每对必须输出一个 decision
29
+ - 只输出 JSON 数组,不要其他文字`;
30
+
31
+ const PATTERN_PROMPT = `你是记忆库模式发现助手。下面是最近的记忆条目(id、类型、标题、内容)。
32
+ 请发现跨条目的稳定模式:用户偏好的规律、反复出现的主题、可复用的工作流或项目规律。
33
+ 对每个模式输出一个 create decision:
34
+ { "action": "create", "type": "pattern", "title": "模式一句话标题", "content": "模式详细描述(2-4句)", "importance": 1-5, "evidence": ["支持该模式的记忆id"] }
35
+ 规则:
36
+ - 只输出有据可依的模式,宁缺毋滥
37
+ - evidence 必须是列表中真实存在的 id
38
+ - 最多输出 N 个模式
39
+ - 只输出 JSON 数组,不要其他文字`;
40
+
41
+ function parseJsonArray(text) {
42
+ if (typeof text !== "string") return undefined;
43
+ const start = text.indexOf("[");
44
+ const end = text.lastIndexOf("]");
45
+ if (start === -1 || end <= start) return undefined;
46
+ try {
47
+ const parsed = JSON.parse(text.slice(start, end + 1));
48
+ return Array.isArray(parsed) ? parsed : undefined;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /** Same stream consumption contract as dream.js. */
55
+ async function streamText(ctx, options) {
56
+ if (!ctx?.llm?.stream) return undefined;
57
+ let text = "";
58
+ for await (const chunk of ctx.llm.stream(options)) {
59
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
60
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
61
+ return undefined;
62
+ }
63
+ }
64
+ return text;
65
+ }
66
+
67
+ /** LLM route: agent default model first, then sleepProvider/Model, then the
68
+ * dream route as a shared fallback. Sleep can pin a cheaper model for its
69
+ * bulk passes without disturbing the dream route. */
70
+ function resolveSleepRoute(ctx, config, logger) {
71
+ try {
72
+ const sel = ctx?.agentDefaultModel?.currentSelection?.();
73
+ if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
74
+ } catch { /* fall through to config route */ }
75
+ if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
76
+ if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
77
+ logger?.warn?.("dsh-mneme sleep: no llm route available");
78
+ return undefined;
79
+ }
80
+
81
+ function makeSummary(m) {
82
+ const text = (m.content ?? "").trim();
83
+ if (!text) return (m.title ?? "").trim();
84
+ return text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX)}…`;
85
+ }
86
+
87
+ // ---------------------------------------------------------------- phases
88
+
89
+ /**
90
+ * Phase 1 — conflict resolution. Needs a semantic embedder + vector index.
91
+ * In freeze mode (conflictFreezeEnabled) conflicting pairs are parked in
92
+ * conflict_pending for human review (no LLM). Otherwise the LLM adjudicates:
93
+ * each pair → winner kept / loser archived. Returns a per-run summary.
94
+ */
95
+ async function phaseConflicts(ctx, service, config, logger, runId, semantic = null) {
96
+ const embedder = semantic?.embedder;
97
+ const vectorIndex = semantic?.vectorIndex;
98
+ if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
99
+ return { status: "skipped", reason: "no semantic embedder" };
100
+ }
101
+ const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
102
+ if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
103
+
104
+ // Backfill + collect vectors for every eligible memory (best effort).
105
+ const vectors = new Array(memories.length);
106
+ const missing = [];
107
+ for (let i = 0; i < memories.length; i++) {
108
+ const cached = vectorIndex.getEmbedding?.(memories[i].id);
109
+ if (cached) vectors[i] = cached;
110
+ else missing.push(i);
111
+ }
112
+ if (missing.length) {
113
+ try {
114
+ const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
115
+ const rows = await embedder.embed(texts);
116
+ missing.forEach((mi, j) => {
117
+ if (rows[j]?.length) {
118
+ vectors[mi] = rows[j];
119
+ vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
120
+ }
121
+ });
122
+ } catch (error) {
123
+ logger?.warn?.(`dsh-mneme sleep: conflict vector backfill failed: ${String(error)}`);
124
+ }
125
+ }
126
+ const usable = [];
127
+ for (let i = 0; i < memories.length; i++) {
128
+ if (vectors[i]?.length) usable.push(i);
129
+ }
130
+ if (usable.length < 2) return { status: "skipped", reason: "no usable vectors" };
131
+ const usableMemories = usable.map((i) => memories[i]);
132
+ const usableVectors = usable.map((i) => vectors[i]);
133
+
134
+ const pairs = findPotentialConflicts(usableMemories, usableVectors, CONFLICT_THRESHOLD);
135
+ if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
136
+
137
+ // Dedupe: each memory participates in at most one pair, highest similarity
138
+ // first — overlapping pairs would violate validateDecisions' "one claim".
139
+ pairs.sort((a, b) => b.similarity - a.similarity);
140
+ const used = new Set();
141
+ const selected = [];
142
+ for (const p of pairs) {
143
+ if (used.has(p.a.id) || used.has(p.b.id)) continue;
144
+ used.add(p.a.id);
145
+ used.add(p.b.id);
146
+ selected.push(p);
147
+ }
148
+
149
+ // Freeze mode: park pairs for manual review, no LLM required.
150
+ if (config.conflictFreezeEnabled === true) {
151
+ let frozen = 0;
152
+ for (const p of selected) {
153
+ try {
154
+ service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason: `相似度 ${p.similarity.toFixed(2)}` });
155
+ frozen++;
156
+ } catch (error) {
157
+ logger?.warn?.(`dsh-mneme sleep: failed to freeze conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
158
+ }
159
+ }
160
+ return { status: frozen > 0 ? "ok" : "noop", frozen, pairs: selected.length };
161
+ }
162
+
163
+ // LLM adjudication.
164
+ const route = resolveSleepRoute(ctx, config, logger);
165
+ if (!route) return { status: "skipped", reason: "no llm route" };
166
+ const snapshot = new Map();
167
+ for (const p of selected) {
168
+ snapshot.set(p.a.id, p.a);
169
+ snapshot.set(p.b.id, p.b);
170
+ }
171
+ const listText = selected.map((p) =>
172
+ `候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`
173
+ ).join("\n\n");
174
+ const text = await streamText(ctx, {
175
+ provider: route.provider,
176
+ model: route.model,
177
+ purpose: "sleep-conflict",
178
+ maxTokens: 2048,
179
+ messages: [
180
+ { role: "system", content: [{ type: "text", text: CONFLICT_PROMPT }] },
181
+ { role: "user", content: [{ type: "text", text: listText }] }
182
+ ]
183
+ });
184
+ if (text === undefined) return { status: "failed", error: "llm failed" };
185
+ const decisions = parseJsonArray(text);
186
+ if (!decisions) return { status: "failed", error: "invalid decisions json" };
187
+ // validateDecisions requires every snapshot id to be claimed exactly once.
188
+ // An LLM that omits a pair would otherwise fail the whole phase, so any
189
+ // snapshot id the output leaves uncovered is defaulted to `keep` — the
190
+ // fail-safe reading is "no conflict decided" rather than "conflict phase
191
+ // aborted". validateDecisions stays strict for the dream consolidation path.
192
+ const covered = new Set();
193
+ for (const d of decisions) {
194
+ if (d?.action === "conflict") {
195
+ if (typeof d?.winner === "string") covered.add(d.winner);
196
+ if (typeof d?.loser === "string") covered.add(d.loser);
197
+ } else if (Array.isArray(d?.ids)) {
198
+ for (const id of d.ids) covered.add(id);
199
+ }
200
+ }
201
+ for (const id of snapshot.keys()) {
202
+ if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
203
+ }
204
+ const { ok, errors } = validateDecisions(decisions, snapshot, {});
205
+ if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
206
+ const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
207
+ return {
208
+ status: applied > 0 ? "ok" : failures.length ? "failed" : "noop",
209
+ pairs: selected.length,
210
+ applied,
211
+ failures,
212
+ conflicts
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Phase 2 — archival demotion. No LLM: tiering is time-based, summaries are
218
+ * truncations, and the full body is preserved in _full_content so nothing is
219
+ * lost. Deterministic and cheap, so it runs even with no LLM route.
220
+ */
221
+ function phaseDemotion(service, config, logger, runId) {
222
+ const archiveDays = config.sleepArchiveDays ?? 30;
223
+ const deepArchiveDays = config.sleepDeepArchiveDays ?? 90;
224
+ const archiveCut = Date.now() - archiveDays * 86400000;
225
+ const deepCut = Date.now() - deepArchiveDays * 86400000;
226
+ const demoted = [];
227
+ const archived = [];
228
+ for (const m of service.all()) {
229
+ if (m.archived || m.forgotten) continue;
230
+ const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
231
+ if (!ref) continue;
232
+ const t = new Date(ref).getTime();
233
+ if (Number.isNaN(t)) continue;
234
+ if (t < deepCut) {
235
+ service.setArchived(m.id, true);
236
+ archived.push(m.id);
237
+ } else if (t < archiveCut) {
238
+ // minRefTimeMs re-checks freshness inside demoteToSummary's transaction:
239
+ // a recall touch landing after this snapshot must not demote the memory.
240
+ service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
241
+ demoted.push(m.id);
242
+ }
243
+ }
244
+ return {
245
+ status: demoted.length || archived.length ? "ok" : "noop",
246
+ demoted,
247
+ archived
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Phase 3 — pattern discovery. The LLM scans the most recent memories and
253
+ * mints type=pattern entries (create actions) with evidence references.
254
+ * The empty snapshot is intentional: create claims no existing id, so the
255
+ * "every id claimed" invariant is trivially satisfied for pure-create lists.
256
+ */
257
+ async function phasePatterns(ctx, service, config, logger, runId) {
258
+ const route = resolveSleepRoute(ctx, config, logger);
259
+ if (!route) return { status: "skipped", reason: "no llm route" };
260
+ const limit = config.sleepPatternScanCount ?? 100;
261
+ const memories = service
262
+ .list({ limit: 200, includeForgotten: false })
263
+ .filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
264
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
265
+ .slice(0, limit);
266
+ if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
267
+ const listText = memories
268
+ .map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
269
+ .join("\n");
270
+ const maxPatterns = config.sleepMaxPatterns ?? 5;
271
+ const text = await streamText(ctx, {
272
+ provider: route.provider,
273
+ model: route.model,
274
+ purpose: "sleep-pattern",
275
+ maxTokens: 2048,
276
+ messages: [
277
+ { role: "system", content: [{ type: "text", text: PATTERN_PROMPT.replace("N", String(maxPatterns)) }] },
278
+ { role: "user", content: [{ type: "text", text: listText }] }
279
+ ]
280
+ });
281
+ if (text === undefined) return { status: "failed", error: "llm failed" };
282
+ const decisions = parseJsonArray(text);
283
+ if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
284
+ // Evidence ids are provenance refs; an LLM-fabricated id would mint a dead
285
+ // ev:tag pointing nowhere. Intersect evidence with the scanned set so only
286
+ // real memory references survive.
287
+ const scannedIds = new Set(memories.map((m) => m.id));
288
+ for (const d of decisions) {
289
+ if (d?.action === "create" && Array.isArray(d.evidence)) {
290
+ d.evidence = d.evidence.filter((id) => typeof id === "string" && scannedIds.has(id));
291
+ }
292
+ }
293
+ const snapshot = new Map();
294
+ const { ok, errors } = validateDecisions(decisions, snapshot, {
295
+ maxCreatePerRun: maxPatterns
296
+ });
297
+ if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
298
+ const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
299
+ return {
300
+ status: applied > 0 ? "ok" : "noop",
301
+ scanned: memories.length,
302
+ applied,
303
+ failures,
304
+ conflicts
305
+ };
306
+ }
307
+
308
+ // ---------------------------------------------------------------- run
309
+
310
+ function deriveStatus(phases) {
311
+ const list = Object.values(phases);
312
+ if (list.length === 0) return "noop";
313
+ const anyError = list.some((p) => p.status === "failed" || p.status === "error");
314
+ const anyWork = list.some((p) => p.status === "ok");
315
+ if (anyWork && anyError) return "degraded";
316
+ if (anyError) return "failed";
317
+ if (anyWork) return "ok";
318
+ return "noop";
319
+ }
320
+
321
+ /**
322
+ * Run one full sleep cycle. Best-effort across all phases; writes a
323
+ * run_type='sleep' audit row (same dream_runs table) so sleep activity is
324
+ * observable alongside consolidation runs.
325
+ */
326
+ export async function runSleep(ctx, service, config, logger, semantic = null) {
327
+ const runId = randomUUID();
328
+ const phases = {};
329
+ const attempt = async (name, fn) => {
330
+ try {
331
+ phases[name] = await fn();
332
+ } catch (error) {
333
+ phases[name] = { status: "failed", error: error?.message ?? String(error) };
334
+ logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
335
+ }
336
+ };
337
+ await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic));
338
+ await attempt("demotion", () => phaseDemotion(service, config, logger, runId));
339
+ await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId));
340
+
341
+ const status = deriveStatus(phases);
342
+ const route = resolveSleepRoute(ctx, config, logger);
343
+ const totalApplied = Object.values(phases).reduce((n, p) => n + (Number.isInteger(p?.applied) ? p.applied : 0), 0);
344
+ const snapshotHash = createHash("sha256").update(JSON.stringify(phases)).digest("hex");
345
+ const receipt = buildReceipt({
346
+ runId,
347
+ status,
348
+ snapshotHash,
349
+ inputCount: 0,
350
+ applied: totalApplied,
351
+ summaryStored: false
352
+ });
353
+ try {
354
+ service.saveDreamRun({
355
+ id: runId,
356
+ status,
357
+ provider: route?.provider,
358
+ model: route?.model,
359
+ snapshot_hash: snapshotHash,
360
+ input_count: 0,
361
+ input: null,
362
+ decisions: phases,
363
+ outcome: phases,
364
+ applied: totalApplied,
365
+ summary_stored: false,
366
+ receipt,
367
+ policy_epoch: config.policyEpoch ?? 0,
368
+ run_type: "sleep"
369
+ });
370
+ } catch (error) {
371
+ logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);
372
+ }
373
+ return { ok: status === "ok" || status === "degraded", status, runId, phases, receipt };
374
+ }
375
+
376
+ // ---------------------------------------------------------------- scheduler
377
+
378
+ /**
379
+ * Idle-triggered scheduler. DSH plugins have no resident cron, so a sleep run
380
+ * fires when: sleep is enabled, the store has been quiet for sleepIdleMinutes,
381
+ * and the previous run is older than sleepMinIntervalHours. noteWrite() is
382
+ * called on every store write and (re)arms an idle timer that re-checks at the
383
+ * exact moment the idle window elapses — no polling, no cron.
384
+ *
385
+ * A `now` clock can be injected for tests; it defaults to Date.now.
386
+ */
387
+ export function createSleepScheduler({
388
+ service,
389
+ config,
390
+ logger,
391
+ onRun = null,
392
+ now = () => Date.now(),
393
+ setTimeoutFn = setTimeout,
394
+ clearTimeoutFn = clearTimeout
395
+ }) {
396
+ let lastWriteAt = now();
397
+ let lastRunAt = 0;
398
+ let running = false;
399
+ let disposed = false;
400
+ let idleTimer = null;
401
+
402
+ function armIdleTimer() {
403
+ if (disposed || idleTimer) return;
404
+ if (config.sleepEnabled !== true) return;
405
+ const idleMs = (config.sleepIdleMinutes ?? 30) * 60000;
406
+ const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
407
+ idleTimer = setTimeoutFn(async () => {
408
+ idleTimer = null;
409
+ await maybeSchedule();
410
+ }, delay);
411
+ idleTimer.unref?.();
412
+ }
413
+
414
+ function shouldRun(at = now()) {
415
+ if (disposed || running) return false;
416
+ if (config.sleepEnabled !== true) return false;
417
+ if (at - lastWriteAt < (config.sleepIdleMinutes ?? 30) * 60000) return false;
418
+ if (at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
419
+ return true;
420
+ }
421
+
422
+ /** Called on writes: resets the idle clock and re-arms the fire timer. The
423
+ * pending timer is cleared first — a stale timer armed against the old idle
424
+ * window would otherwise fire early, fail shouldRun, and leave nothing armed
425
+ * for the next window (a missed trigger until the next write). */
426
+ function noteWrite() {
427
+ lastWriteAt = now();
428
+ if (idleTimer) {
429
+ clearTimeoutFn(idleTimer);
430
+ idleTimer = null;
431
+ }
432
+ armIdleTimer();
433
+ }
434
+
435
+ async function maybeSchedule() {
436
+ if (!shouldRun()) return false;
437
+ running = true;
438
+ try {
439
+ lastRunAt = now();
440
+ const result = await service.enqueue(() =>
441
+ onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })
442
+ );
443
+ return !!(result && result.ok);
444
+ } catch (error) {
445
+ logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
446
+ return false;
447
+ } finally {
448
+ running = false;
449
+ }
450
+ }
451
+
452
+ async function dispose() {
453
+ disposed = true;
454
+ if (idleTimer) {
455
+ clearTimeoutFn(idleTimer);
456
+ idleTimer = null;
457
+ }
458
+ }
459
+
460
+ return { noteWrite, maybeSchedule, shouldRun, dispose };
461
+ }
package/lib/store.js CHANGED
@@ -186,7 +186,7 @@ CREATE TABLE IF NOT EXISTS mirror_state (
186
186
  );
187
187
  `;
188
188
 
189
- const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
189
+ const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
190
190
 
191
191
  // Per-type mirror sync receipts (peer blocker 4): a type is either committed
192
192
  // (file written + fence applied), failed (last sync round errored for it), or
@@ -227,7 +227,12 @@ function toRow(row) {
227
227
  archived: row.archived === 1,
228
228
  source: row.source ?? undefined,
229
229
  created_at: row.created_at,
230
- updated_at: row.updated_at
230
+ updated_at: row.updated_at,
231
+ // Sleep (v0.4.1): last_accessed_at drives the unrecalled tiering;
232
+ // _full_content holds the pre-demotion body for a memory reduced to its
233
+ // summary. Both are internal — the mirror render must not expose them.
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
 
@@ -412,6 +418,19 @@ export function createStore(path) {
412
418
  if (!dreamCols.includes("policy_epoch")) {
413
419
  db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
414
420
  }
421
+ if (!dreamCols.includes("run_type")) {
422
+ db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
423
+ }
424
+
425
+ // Sleep (v0.4.1) columns: last_accessed_at tracks recall/inject touch for the
426
+ // "unrecalled N days → demote/archive" tiering; _full_content holds the pre-demotion
427
+ // body when a memory is reduced to its summary. Both are additive-only.
428
+ if (!columns.includes("last_accessed_at")) {
429
+ db.exec("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
430
+ }
431
+ if (!columns.includes("_full_content")) {
432
+ db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
433
+ }
415
434
 
416
435
  // Legacy mirror_state without v0.3.6 generation columns → add each missing
417
436
  // column idempotently (old DBs open cleanly, no data loss).
@@ -591,6 +610,48 @@ export function createStore(path) {
591
610
  return getById(id);
592
611
  }
593
612
 
613
+ /** Recall/inject touch. Records last_accessed_at WITHOUT bumping the mirror
614
+ * generation — sleep's "unrecalled N days → demote/archive" tiering must not
615
+ * spin the desired generation (that would mislead the mirror peer into
616
+ * thinking the touched memory's content changed). Only bumps a timestamp,
617
+ * so it is safe on hot recall paths. */
618
+ function touchAccess(id) {
619
+ const result = db.prepare(
620
+ "UPDATE memories SET last_accessed_at = ? WHERE id = ?"
621
+ ).run(nowIso(), id);
622
+ return result.changes > 0;
623
+ }
624
+
625
+ /** Sleep demotion (v0.4.1): move the full body into _full_content and replace
626
+ * content with a one-line summary. Skips when already demoted (_full_content
627
+ * present) so a replayed sleep run never double-wraps. Bumps the mirror
628
+ * generation because content visibly changes in the mirror file.
629
+ *
630
+ * minRefTimeMs (optional): the sleep tiering's freshness cutoff. A memory
631
+ * whose reference time (last_accessed_at ?? updated_at ?? created_at) is
632
+ * newer than the cutoff is skipped — phaseDemotion snapshots ref times up
633
+ * front, and a recall touch landing between the snapshot and this call must
634
+ * not demote a freshly-accessed memory. The check and the update run in the
635
+ * same synchronous transaction, so the read-then-write is atomic. */
636
+ function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
637
+ runAtomically(() => {
638
+ const row = db.prepare(
639
+ "SELECT content, _full_content, last_accessed_at, updated_at, created_at FROM memories WHERE id = ?"
640
+ ).get(id);
641
+ if (!row || row._full_content) return; // idempotent: never double-wrap
642
+ if (minRefTimeMs != null) {
643
+ const ref = row.last_accessed_at ?? row.updated_at ?? row.created_at;
644
+ const t = ref ? new Date(ref).getTime() : NaN;
645
+ if (!Number.isNaN(t) && t >= minRefTimeMs) return; // freshly touched: keep full
646
+ }
647
+ db.prepare(
648
+ "UPDATE memories SET _full_content = ?, content = ?, updated_at = ? WHERE id = ?"
649
+ ).run(row.content ?? "", summary ?? "", nowIso(), id);
650
+ incrementGeneration();
651
+ });
652
+ return getById(id);
653
+ }
654
+
594
655
  function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
595
656
  const clauses = [];
596
657
  const params = [];
@@ -716,16 +777,17 @@ export function createStore(path) {
716
777
  const id = run.id ?? randomUUID();
717
778
  const now = nowIso();
718
779
  const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
780
+ const runType = run.run_type ?? "auto";
719
781
  db.prepare(
720
782
  `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
783
+ input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
784
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
723
785
  ON CONFLICT(id) DO UPDATE SET
724
786
  created_at=excluded.created_at, status=excluded.status, error=excluded.error,
725
787
  provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
726
788
  input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
727
789
  outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
728
- receipt=excluded.receipt, policy_epoch=excluded.policy_epoch`
790
+ receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
729
791
  ).run(
730
792
  id,
731
793
  run.created_at ?? now,
@@ -741,7 +803,8 @@ export function createStore(path) {
741
803
  run.applied ?? 0,
742
804
  run.summary_stored ? 1 : 0,
743
805
  run.receipt,
744
- policyEpoch
806
+ policyEpoch,
807
+ runType
745
808
  );
746
809
  return getDreamRun(id);
747
810
  }
@@ -1374,6 +1437,8 @@ export function createStore(path) {
1374
1437
  remove,
1375
1438
  setForget,
1376
1439
  setArchived,
1440
+ touchAccess,
1441
+ demoteToSummary,
1377
1442
  list,
1378
1443
  all,
1379
1444
  search,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.3.8",
4
+ "version": "0.4.1",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/config.js CHANGED
@@ -91,4 +91,32 @@ export const Config = z.object({
91
91
  entityExtractionMaxAttrs: z.natural().min(1).max(50).default(20),
92
92
  // Prefix/semantic search over entity names (used by recall).
93
93
  entitySearchEnabled: z.boolean().default(true),
94
+
95
+ // --- system-level sleep (v0.4.1) -----------------------------------------
96
+ // Opt-in: when false (default) the plugin never runs a sleep cycle, so the
97
+ // access-touch bookkeeping on recall/inject paths stays off too. Sleep is
98
+ // three phases: conflict resolution (reuses the dream conflict machinery),
99
+ // archival demotion (unrecalled memories tier down to summary then archive),
100
+ // and pattern discovery (LLM scans recent memories and mints type=pattern
101
+ // entries with evidence references).
102
+ sleepEnabled: z.boolean().default(false),
103
+ // A sleep run only fires when the store has been idle for this long and the
104
+ // last run is older than sleepMinIntervalHours. Idle detection replaces a
105
+ // cron-like schedule (DSH plugins have no resident crontab).
106
+ sleepIdleMinutes: z.natural().min(1).max(1440).default(30),
107
+ sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
108
+ // Unrecalled (COALESCE(last_accessed_at, updated_at, created_at)) beyond
109
+ // sleepArchiveDays → demote: full body moves to _full_content, content
110
+ // becomes a one-line summary. Beyond sleepDeepArchiveDays → archive.
111
+ sleepArchiveDays: z.natural().min(1).max(365).default(30),
112
+ sleepDeepArchiveDays: z.natural().min(1).max(3650).default(90),
113
+ // How many most-recent memories the pattern-discovery pass scans.
114
+ sleepPatternScanCount: z.natural().min(10).max(500).default(100),
115
+ // Max patterns minted per sleep run (mirrors decisions maxCreatePerRun).
116
+ sleepMaxPatterns: z.natural().min(1).max(20).default(5),
117
+ // Optional LLM route override; empty = fall back to agentDefaultModel then
118
+ // the dream route. Distinct from dreamProvider/dreamModel so the sleep pass
119
+ // can pin a cheaper model for its bulk summarization.
120
+ sleepProvider: z.string().default(""),
121
+ sleepModel: z.string().default(""),
94
122
  });