@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/README.md +42 -8
- package/lib/api.js +1 -1
- package/lib/config.js +60 -25
- package/lib/dream/decisions.js +33 -63
- package/lib/{sleep.js → dream/sleep.js} +124 -29
- package/lib/dream.js +6 -0
- package/lib/index.js +9 -12
- package/lib/mirror.js +24 -12
- package/lib/service.js +115 -49
- package/lib/store.js +144 -75
- package/lib/summarize.js +6 -3
- package/package.json +2 -2
- package/scripts/e2e-dsh.js +4 -2
- package/src/api.js +1 -1
- package/src/config.js +60 -25
- package/src/dream/decisions.js +33 -63
- package/src/{sleep.js → dream/sleep.js} +124 -29
- package/src/dream.js +6 -0
- package/src/index.js +9 -12
- package/src/mirror.js +24 -12
- package/src/service.js +115 -49
- package/src/store.js +144 -75
- package/src/summarize.js +6 -3
- package/test/mirror-generation.test.js +34 -1
- package/test/peer-blockers.test.js +42 -0
- package/test/reasoning-effort.test.js +172 -0
- package/test/sleep.test.js +297 -333
- package/test/summarize.test.js +35 -0
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(
|
|
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
|
-
// ---
|
|
96
|
-
// Opt-in
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
});
|
package/src/dream/decisions.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
48
|
-
errors.push(`${at}: create
|
|
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
|
|
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,
|
|
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
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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,
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
content
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
2
|
-
// the memory store.
|
|
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
|
-
//
|
|
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 "./
|
|
15
|
-
import { findPotentialConflicts } from "./
|
|
16
|
-
import { buildReceipt } from "
|
|
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
|
-
|
|
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,
|
|
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
|
|
237
|
+
const compressDays = config.sleepCompressDays ?? 90;
|
|
224
238
|
const archiveCut = Date.now() - archiveDays * 86400000;
|
|
225
|
-
const
|
|
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 <
|
|
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.
|
|
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.
|
|
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.
|
|
405
|
-
const idleMs = (config.sleepIdleMinutes ??
|
|
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.
|
|
417
|
-
if (at - lastWriteAt < (config.sleepIdleMinutes ??
|
|
418
|
-
|
|
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.
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
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.
|
|
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)
|
|
271
|
+
if (sleep) sleep.dispose();
|
|
275
272
|
store.close();
|
|
276
273
|
};
|
|
277
274
|
};
|