@modusensus/dsh-mneme 0.4.1 → 0.4.3-beta.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/src/config.js CHANGED
@@ -4,6 +4,11 @@ export const Config = z.object({
4
4
  memoryDir: z.string().default("~/.dsh/memory"),
5
5
  autoInject: z.boolean().default(true),
6
6
  autoSummarize: z.boolean().default(true),
7
+ // Optional model override for summarization. When both are non-empty, they
8
+ // take priority over the session's current model. Empty = use the session's
9
+ // active provider/model (same as before).
10
+ summarizeProvider: z.string().default(""),
11
+ summarizeModel: z.string().default(""),
7
12
  maxInjectedItems: z.natural().min(1).max(20).default(5),
8
13
  importanceThreshold: z.natural().min(1).max(5).default(3),
9
14
  autoDream: z.boolean().default(true),
@@ -12,7 +17,18 @@ export const Config = z.object({
12
17
  dreamDelayMs: z.natural().min(0).max(60000).default(2000),
13
18
  dreamProvider: z.string(),
14
19
  dreamModel: z.string(),
15
- dreamMaxTokens: z.natural().min(256).max(32768).default(4096),
20
+ dreamMaxTokens: z.natural().min(256).max(131072).default(4096),
21
+ // Pass-through reasoning effort for dream's LLM calls. 'none' (default)
22
+ // omits the field so the provider's own default applies; low/medium/high
23
+ // are forwarded verbatim. Useful to cap reasoning spend on thinking-type
24
+ // models that would otherwise drain the whole token budget and return an
25
+ // empty body ("no json array in llm output").
26
+ dreamReasoningEffort: z.union([
27
+ z.const("low"),
28
+ z.const("medium"),
29
+ z.const("high"),
30
+ z.const("none")
31
+ ]).default("none"),
16
32
  // Rule version for dream adjudication: when this bumps, older dream_runs
17
33
  // degrade to historical evidence (their receipts no longer drive live
18
34
  // decisions). Default 0 = no versioning in use yet.
@@ -92,31 +108,50 @@ export const Config = z.object({
92
108
  // Prefix/semantic search over entity names (used by recall).
93
109
  entitySearchEnabled: z.boolean().default(true),
94
110
 
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),
111
+ // --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
112
+ // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
113
+ // sleep fires when the store has been quiet for sleepIdleMinutes and deep-
114
+ // maintains the whole library: conflict resolution, archival demotion,
115
+ // pattern discovery and entity relation completion. Abortable on user
116
+ // activity, audited into dream_runs (run_type='sleep'), and serialized with
117
+ // autoDream so the two never overlap.
118
+ sleepModeEnabled: z.boolean().default(false),
119
+ // Quiet window before a cycle fires (minutes).
120
+ sleepIdleMinutes: z.natural().min(1).max(60).default(5),
121
+ // Minimum gap between two sleep runs (hours) a second idle window within
122
+ // this interval does not retrigger.
107
123
  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.
124
+ // Conflict adjudication strictness:
125
+ // gentle only high-confidence conflicts (threshold 0.92) are resolved
126
+ // normal standard dream-level (threshold 0.85)
127
+ // aggressive low-confidence pairs are also adjudicated (threshold 0.75)
128
+ sleepConflictStrictness: z.union([
129
+ z.const("gentle"),
130
+ z.const("normal"),
131
+ z.const("aggressive")
132
+ ]).default("normal"),
133
+ // Archival demotion tiering (days since last access):
134
+ // >= sleepArchiveDays → shrink to summary, full body kept in _full_content
135
+ // >= sleepCompressDays archived outright (entity relations preserved)
136
+ sleepArchiveDays: z.natural().min(7).max(365).default(30),
137
+ sleepCompressDays: z.natural().min(7).max(365).default(90),
138
+ // Pattern discovery scan window (most recent memories to scan).
139
+ sleepPatternMinMemories: z.natural().min(10).max(1000).default(100),
140
+ // How far back pattern discovery considers entity attr changes (days).
141
+ sleepPatternLookbackDays: z.natural().min(1).max(90).default(30),
142
+ // Max pattern memories minted per run (0 = disabled).
143
+ sleepMaxPatternPerRun: z.natural().min(0).max(10).default(3),
144
+ // Optional LLM route override for sleep's bulk passes (empty = use dream
145
+ // route / agent default model).
120
146
  sleepProvider: z.string().default(""),
121
147
  sleepModel: z.string().default(""),
148
+ // Pass-through reasoning effort for sleep's LLM passes, same semantics as
149
+ // dreamReasoningEffort: 'none' (default) omits the field; low/medium/high
150
+ // are forwarded verbatim.
151
+ sleepReasoningEffort: z.union([
152
+ z.const("low"),
153
+ z.const("medium"),
154
+ z.const("high"),
155
+ z.const("none")
156
+ ]).default("none"),
122
157
  });
@@ -1,9 +1,5 @@
1
1
  const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
2
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"]);
6
-
7
3
  /**
8
4
  * Validate a dream decision list against a snapshot of eligible memories.
9
5
  * @param decisions - LLM-produced decision list.
@@ -13,7 +9,6 @@ const CREATE_TYPES = new Set(["pattern", "preference", "project", "decision", "h
13
9
  export function validateDecisions(decisions, snapshot, options = {}) {
14
10
  const errors = [];
15
11
  const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
16
- const maxCreatePerRun = options.maxCreatePerRun ?? 5;
17
12
  const minAgeHours = options.minAgeHours ?? 24;
18
13
  if (!Array.isArray(decisions) || decisions.length === 0) {
19
14
  return { ok: false, errors: ["decision list must be a non-empty array"] };
@@ -25,9 +20,16 @@ export function validateDecisions(decisions, snapshot, options = {}) {
25
20
  errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
26
21
  continue;
27
22
  }
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") {
23
+ const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
24
+ if (d.action === "conflict") {
25
+ if (!d.winner || !d.loser || d.winner === d.loser) {
26
+ errors.push(`${at}: conflict needs distinct winner and loser`);
27
+ continue;
28
+ }
29
+ } else if (d.action === "create") {
30
+ // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
31
+ // so it skips the claiming loop below; evidence is optional provenance
32
+ // (already filtered to real ids by the caller) and is stored in content.
31
33
  if (typeof d.title !== "string" || !d.title.trim()) {
32
34
  errors.push(`${at}: create needs non-empty title`);
33
35
  continue;
@@ -38,28 +40,11 @@ export function validateDecisions(decisions, snapshot, options = {}) {
38
40
  }
39
41
  if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
40
42
  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
43
  }
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;
44
+ if (typeof d.type !== "string" || !d.type.trim()) {
45
+ errors.push(`${at}: create needs non-empty type`);
54
46
  }
55
47
  continue;
56
- }
57
- const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
58
- if (d.action === "conflict") {
59
- if (!d.winner || !d.loser || d.winner === d.loser) {
60
- errors.push(`${at}: conflict needs distinct winner and loser`);
61
- continue;
62
- }
63
48
  } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
64
49
  errors.push(`${at}: ${d.action} needs non-empty ids`);
65
50
  continue;
@@ -129,9 +114,9 @@ export function validateDecisions(decisions, snapshot, options = {}) {
129
114
  if (updateCount > maxUpdatePerRun) {
130
115
  errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
131
116
  }
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.
117
+ // Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
134
118
  const createCount = decisions.filter((d) => d.action === "create").length;
119
+ const maxCreatePerRun = options.maxCreatePerRun ?? 5;
135
120
  if (createCount > maxCreatePerRun) {
136
121
  errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
137
122
  }
@@ -242,46 +227,31 @@ function applyOne(d, service, snapshot, config = {}) {
242
227
  case "archive": return applyArchive(d, service, snapshot);
243
228
  case "merge": return applyMerge(d, service, snapshot, config);
244
229
  case "conflict": return applyConflict(d, service, snapshot);
245
- case "create": return applyCreate(d, service, snapshot);
230
+ case "create": return applyCreate(d, service, config);
246
231
  default: return applyUpdate(d, service, snapshot, config);
247
232
  }
248
233
  }
249
234
 
250
235
  /**
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.
236
+ * Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
237
+ * Evidence ids ride in the content so a pattern stays traceable to its source
238
+ * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
255
239
  */
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
- };
240
+ function applyCreate(d, service, config = {}) {
241
+ const title = String(d.title ?? "").trim();
242
+ const content = String(d.content ?? "").trim();
243
+ const importance = Number.isInteger(d.importance) ? d.importance : 3;
244
+ const type = typeof d.type === "string" ? d.type : "pattern";
245
+ const evidence = Array.isArray(d.evidence)
246
+ ? d.evidence.filter((id) => typeof id === "string")
247
+ : [];
248
+ const body = evidence.length > 0
249
+ ? `${content}\n\n[证据: ${evidence.join(", ")}]`
250
+ : content;
251
+ const created = service.saveWithDedupe({ type, title, content: body, importance });
252
+ const memory = created?.memory;
253
+ if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
254
+ return { applied: 1, committed: { action: "create", id: memory.id, type } };
285
255
  }
286
256
 
287
257
  function applyArchive(d, service, snapshot) {
@@ -1,22 +1,30 @@
1
- // System-level sleep (v0.4.1): an idle-triggered, LLM-assisted deep pass over
2
- // the memory store. Three independent, fail-safe phases:
1
+ // System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
2
+ // the memory store. Four independent, fail-safe phases:
3
3
  // 1. conflict resolution — high-similarity same-type pairs are either parked
4
4
  // for review (freeze mode) or adjudicated by the LLM (winner kept / loser
5
- // archived), reusing the dream conflict machinery.
5
+ // archived), reusing the dream conflict machinery. Strictness-graded.
6
6
  // 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
7
7
  // to a one-line summary with the full body moved to _full_content; past
8
- // sleepDeepArchiveDays they are archived outright.
8
+ // sleepCompressDays they are archived outright.
9
9
  // 3. pattern discovery — the LLM scans the most recent memories and mints
10
10
  // type=pattern entries carrying evidence id references.
11
+ // 4. relation completion — orphan entities (zero relations) get implied
12
+ // relations completed from memory co-occurrence.
11
13
  // 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.
14
+ // LLM route / semantic embedder only skips the phases that need it. A run is
15
+ // abortable via an AbortController signal (user activity) — phases check the
16
+ // signal between batches so a running cycle yields promptly.
13
17
  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";
18
+ import { validateDecisions, applyDecisions } from "./decisions.js";
19
+ import { findPotentialConflicts } from "./clustering.js";
20
+ import { buildReceipt } from "../dream.js";
17
21
 
18
22
  const SUMMARY_MAX = 120;
19
- const CONFLICT_THRESHOLD = 0.85;
23
+ // Conflict similarity threshold per strictness level (v0.4.0):
24
+ // gentle only high-confidence pairs (0.92) — first-time users
25
+ // normal standard dream-level (0.85) — default
26
+ // aggressive low-confidence pairs too (0.75) — bloated stores
27
+ const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
20
28
 
21
29
  const CONFLICT_PROMPT = `你是记忆库冲突仲裁助手。下面是检测到的高相似度记忆对,可能内容矛盾或重复。
22
30
  对每一对输出一个 decision 对象:
@@ -92,14 +100,17 @@ function makeSummary(m) {
92
100
  * conflict_pending for human review (no LLM). Otherwise the LLM adjudicates:
93
101
  * each pair → winner kept / loser archived. Returns a per-run summary.
94
102
  */
95
- async function phaseConflicts(ctx, service, config, logger, runId, semantic = null) {
103
+ async function phaseConflicts(ctx, service, config, logger, runId, semantic = null, signal = null) {
96
104
  const embedder = semantic?.embedder;
97
105
  const vectorIndex = semantic?.vectorIndex;
98
106
  if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
99
107
  return { status: "skipped", reason: "no semantic embedder" };
100
108
  }
109
+ const strictness = config.sleepConflictStrictness ?? "normal";
110
+ const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
101
111
  const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
102
112
  if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
113
+ if (signal?.aborted) return { status: "aborted", reason: "user activity" };
103
114
 
104
115
  // Backfill + collect vectors for every eligible memory (best effort).
105
116
  const vectors = new Array(memories.length);
@@ -131,7 +142,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
131
142
  const usableMemories = usable.map((i) => memories[i]);
132
143
  const usableVectors = usable.map((i) => vectors[i]);
133
144
 
134
- const pairs = findPotentialConflicts(usableMemories, usableVectors, CONFLICT_THRESHOLD);
145
+ const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
135
146
  if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
136
147
 
137
148
  // Dedupe: each memory participates in at most one pair, highest similarity
@@ -176,6 +187,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
176
187
  model: route.model,
177
188
  purpose: "sleep-conflict",
178
189
  maxTokens: 2048,
190
+ ...(config.sleepReasoningEffort && config.sleepReasoningEffort !== "none"
191
+ ? { reasoningEffort: config.sleepReasoningEffort }
192
+ : {}),
179
193
  messages: [
180
194
  { role: "system", content: [{ type: "text", text: CONFLICT_PROMPT }] },
181
195
  { role: "user", content: [{ type: "text", text: listText }] }
@@ -218,20 +232,21 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
218
232
  * truncations, and the full body is preserved in _full_content so nothing is
219
233
  * lost. Deterministic and cheap, so it runs even with no LLM route.
220
234
  */
221
- function phaseDemotion(service, config, logger, runId) {
235
+ function phaseDemotion(service, config, logger, runId, signal = null) {
222
236
  const archiveDays = config.sleepArchiveDays ?? 30;
223
- const deepArchiveDays = config.sleepDeepArchiveDays ?? 90;
237
+ const compressDays = config.sleepCompressDays ?? 90;
224
238
  const archiveCut = Date.now() - archiveDays * 86400000;
225
- const deepCut = Date.now() - deepArchiveDays * 86400000;
239
+ const compressCut = Date.now() - compressDays * 86400000;
226
240
  const demoted = [];
227
241
  const archived = [];
228
242
  for (const m of service.all()) {
243
+ if (signal?.aborted) break;
229
244
  if (m.archived || m.forgotten) continue;
230
245
  const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
231
246
  if (!ref) continue;
232
247
  const t = new Date(ref).getTime();
233
248
  if (Number.isNaN(t)) continue;
234
- if (t < deepCut) {
249
+ if (t < compressCut) {
235
250
  service.setArchived(m.id, true);
236
251
  archived.push(m.id);
237
252
  } else if (t < archiveCut) {
@@ -254,25 +269,29 @@ function phaseDemotion(service, config, logger, runId) {
254
269
  * The empty snapshot is intentional: create claims no existing id, so the
255
270
  * "every id claimed" invariant is trivially satisfied for pure-create lists.
256
271
  */
257
- async function phasePatterns(ctx, service, config, logger, runId) {
272
+ async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
258
273
  const route = resolveSleepRoute(ctx, config, logger);
259
274
  if (!route) return { status: "skipped", reason: "no llm route" };
260
- const limit = config.sleepPatternScanCount ?? 100;
275
+ const limit = config.sleepPatternMinMemories ?? 100;
261
276
  const memories = service
262
277
  .list({ limit: 200, includeForgotten: false })
263
278
  .filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
264
279
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
265
280
  .slice(0, limit);
266
281
  if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
282
+ if (signal?.aborted) return { status: "aborted", reason: "user activity" };
267
283
  const listText = memories
268
284
  .map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
269
285
  .join("\n");
270
- const maxPatterns = config.sleepMaxPatterns ?? 5;
286
+ const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
271
287
  const text = await streamText(ctx, {
272
288
  provider: route.provider,
273
289
  model: route.model,
274
290
  purpose: "sleep-pattern",
275
291
  maxTokens: 2048,
292
+ ...(config.sleepReasoningEffort && config.sleepReasoningEffort !== "none"
293
+ ? { reasoningEffort: config.sleepReasoningEffort }
294
+ : {}),
276
295
  messages: [
277
296
  { role: "system", content: [{ type: "text", text: PATTERN_PROMPT.replace("N", String(maxPatterns)) }] },
278
297
  { role: "user", content: [{ type: "text", text: listText }] }
@@ -305,6 +324,61 @@ async function phasePatterns(ctx, service, config, logger, runId) {
305
324
  };
306
325
  }
307
326
 
327
+ /**
328
+ * Phase 4 — entity relation completion. Detects orphan entities (zero
329
+ * relations) and completes implied relations from memory co-occurrence:
330
+ * entities named in the same memory → related_to; container kinds
331
+ * (project/module) → part_of; tech-ish pairs → depends_on. Deterministic,
332
+ * no LLM — cheap, so it runs even without a route. saveRelation is
333
+ * bookkeeping (no write hook), so it never re-triggers the scheduler.
334
+ */
335
+ function inferRelationType(a, b) {
336
+ if ((a.type === "project" || a.type === "module") && a.type !== b.type) return "part_of";
337
+ if ((b.type === "project" || b.type === "module") && b.type !== a.type) return "part_of";
338
+ if (/npm|plugin|api|sdk|lib|framework|package|deps?|build/i.test(`${a.name} ${b.name}`)) return "depends_on";
339
+ return "related_to";
340
+ }
341
+
342
+ function phaseRelations(service, config, logger, runId, signal = null) {
343
+ const entities = service.listEntities({ limit: 1000 }) ?? [];
344
+ if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
345
+ const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
346
+ if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
347
+ const memories = service.all().filter((m) => !m.archived && !m.forgotten);
348
+ const seen = new Set();
349
+ const related = [];
350
+ const MAX_RELATIONS_PER_ORPHAN = 3;
351
+ for (const o of orphans) {
352
+ if (signal?.aborted) break;
353
+ let made = 0;
354
+ for (const m of memories) {
355
+ if (signal?.aborted || made >= MAX_RELATIONS_PER_ORPHAN) break;
356
+ const text = `${m.title ?? ""} ${m.content ?? ""}`;
357
+ if (!text.includes(o.name)) continue;
358
+ for (const other of entities) {
359
+ if (other.id === o.id || other.name === o.name) continue;
360
+ const key = [o.id, other.id].sort().join("|");
361
+ if (seen.has(key)) continue;
362
+ if (!text.includes(other.name)) continue;
363
+ const relationType = inferRelationType(o, other);
364
+ try {
365
+ service.saveRelation({ from_entity: o.id, to_entity: other.id, relation_type: relationType, memory_id: m.id, metadata: { source: "sleep_relation_completion" } });
366
+ seen.add(key);
367
+ related.push({ from: o.id, to: other.id, type: relationType });
368
+ made++;
369
+ } catch (error) {
370
+ logger?.warn?.(`dsh-mneme sleep: relation ${o.id}/${other.id} failed: ${String(error)}`);
371
+ }
372
+ }
373
+ }
374
+ }
375
+ return {
376
+ status: related.length > 0 ? "ok" : "noop",
377
+ orphanCount: orphans.length,
378
+ related
379
+ };
380
+ }
381
+
308
382
  // ---------------------------------------------------------------- run
309
383
 
310
384
  function deriveStatus(phases) {
@@ -323,10 +397,11 @@ function deriveStatus(phases) {
323
397
  * run_type='sleep' audit row (same dream_runs table) so sleep activity is
324
398
  * observable alongside consolidation runs.
325
399
  */
326
- export async function runSleep(ctx, service, config, logger, semantic = null) {
400
+ export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
327
401
  const runId = randomUUID();
328
402
  const phases = {};
329
403
  const attempt = async (name, fn) => {
404
+ if (signal?.aborted) return; // user resumed activity — stop before next phase
330
405
  try {
331
406
  phases[name] = await fn();
332
407
  } catch (error) {
@@ -334,9 +409,10 @@ export async function runSleep(ctx, service, config, logger, semantic = null) {
334
409
  logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
335
410
  }
336
411
  };
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));
412
+ await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
413
+ await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
414
+ await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
415
+ await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
340
416
 
341
417
  const status = deriveStatus(phases);
342
418
  const route = resolveSleepRoute(ctx, config, logger);
@@ -398,11 +474,12 @@ export function createSleepScheduler({
398
474
  let running = false;
399
475
  let disposed = false;
400
476
  let idleTimer = null;
477
+ let sleepAbort = null;
401
478
 
402
479
  function armIdleTimer() {
403
480
  if (disposed || idleTimer) return;
404
- if (config.sleepEnabled !== true) return;
405
- const idleMs = (config.sleepIdleMinutes ?? 30) * 60000;
481
+ if (config.sleepModeEnabled !== true) return;
482
+ const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
406
483
  const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
407
484
  idleTimer = setTimeoutFn(async () => {
408
485
  idleTimer = null;
@@ -413,18 +490,29 @@ export function createSleepScheduler({
413
490
 
414
491
  function shouldRun(at = now()) {
415
492
  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;
493
+ if (config.sleepModeEnabled !== true) return false;
494
+ if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
495
+ // lastRunAt === 0 means never ran the min-interval check must not block
496
+ // the very first cycle (a real run stamps a nonzero timestamp).
497
+ if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
419
498
  return true;
420
499
  }
421
500
 
422
501
  /** Called on writes: resets the idle clock and re-arms the fire timer. The
423
502
  * pending timer is cleared first — a stale timer armed against the old idle
424
503
  * window would otherwise fire early, fail shouldRun, and leave nothing armed
425
- * for the next window (a missed trigger until the next write). */
504
+ * for the next window (a missed trigger until the next write).
505
+ *
506
+ * While a sleep run is executing (running=true) the in-flight AbortController
507
+ * is NOT aborted: the run's own writes (demoteToSummary / setArchived ride
508
+ * the normal write-hook path) would otherwise self-abort the cycle. External
509
+ * activity during the run still resets the idle clock here, so no new cycle
510
+ * fires until the store is quiet again. */
426
511
  function noteWrite() {
427
512
  lastWriteAt = now();
513
+ if (!running && sleepAbort) {
514
+ sleepAbort.abort(); // user resumed activity — interrupt an idle run
515
+ }
428
516
  if (idleTimer) {
429
517
  clearTimeoutFn(idleTimer);
430
518
  idleTimer = null;
@@ -435,16 +523,19 @@ export function createSleepScheduler({
435
523
  async function maybeSchedule() {
436
524
  if (!shouldRun()) return false;
437
525
  running = true;
526
+ const abort = new AbortController();
527
+ sleepAbort = abort;
438
528
  try {
439
529
  lastRunAt = now();
440
530
  const result = await service.enqueue(() =>
441
- onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })
531
+ onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
442
532
  );
443
533
  return !!(result && result.ok);
444
534
  } catch (error) {
445
535
  logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
446
536
  return false;
447
537
  } finally {
538
+ sleepAbort = null;
448
539
  running = false;
449
540
  }
450
541
  }
@@ -455,6 +546,10 @@ export function createSleepScheduler({
455
546
  clearTimeoutFn(idleTimer);
456
547
  idleTimer = null;
457
548
  }
549
+ if (sleepAbort) {
550
+ sleepAbort.abort();
551
+ sleepAbort = null;
552
+ }
458
553
  }
459
554
 
460
555
  return { noteWrite, maybeSchedule, shouldRun, dispose };
package/src/dream.js CHANGED
@@ -457,6 +457,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
457
457
  model: route.model,
458
458
  purpose: "compaction",
459
459
  maxTokens: config.dreamMaxTokens ?? 4096,
460
+ ...(config.dreamReasoningEffort && config.dreamReasoningEffort !== "none"
461
+ ? { reasoningEffort: config.dreamReasoningEffort }
462
+ : {}),
460
463
  messages: [
461
464
  { role: "system", content: [{ type: "text", text: consolidationPrompt }] },
462
465
  { role: "user", content: [{ type: "text", text: listText }] }
@@ -600,6 +603,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
600
603
  model: route.model,
601
604
  purpose: "compaction",
602
605
  maxTokens: config.dreamMaxTokens ?? 2048,
606
+ ...(config.dreamReasoningEffort && config.dreamReasoningEffort !== "none"
607
+ ? { reasoningEffort: config.dreamReasoningEffort }
608
+ : {}),
603
609
  messages: [
604
610
  { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
605
611
  { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
package/src/index.js CHANGED
@@ -5,7 +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
+ import { createSleepScheduler, runSleep } from "./dream/sleep.js";
9
9
  import { createApi } from "./api.js";
10
10
  import { createSettings } from "./settings.js";
11
11
  import { createCommandManager } from "./commands.js";
@@ -182,21 +182,18 @@ export const apply = (ctx, config) => {
182
182
  service.setDreamHook(() => dream.maybeSchedule(service));
183
183
  }
184
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.
185
+ // Sleep scheduler (v0.4.0): idle-triggered deep maintenance. Fires when the
186
+ // store has been quiet for sleepIdleMinutes and re-arms on every write via
187
+ // noteWrite (hooked to the service's write path). Runs go through
188
+ // service.enqueue so they serialize with autoDream — the two never overlap.
189
+ // Abortable on user activity; audited into dream_runs with run_type='sleep'.
191
190
  let sleep = null;
192
- if (cfg.sleepEnabled) {
191
+ if (cfg.sleepModeEnabled) {
193
192
  sleep = createSleepScheduler({
194
193
  service,
195
194
  config: cfg,
196
195
  logger: ctx.logger,
197
- onRun: () => (sleep
198
- ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex })
199
- : Promise.resolve({ ok: true, skipped: true }))
196
+ onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
200
197
  });
201
198
  service.setSleepHook(() => sleep.noteWrite());
202
199
  }
@@ -271,7 +268,7 @@ export const apply = (ctx, config) => {
271
268
  }
272
269
  commands?.dispose();
273
270
  if (dream) await dream.dispose();
274
- if (sleep) await sleep.dispose();
271
+ if (sleep) sleep.dispose();
275
272
  store.close();
276
273
  };
277
274
  };