@modusensus/dsh-mneme 0.4.1 → 0.4.2
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 +28 -7
- package/lib/api.js +1 -1
- package/lib/config.js +39 -24
- package/lib/dream/decisions.js +33 -63
- package/lib/{sleep.js → dream/sleep.js} +118 -29
- 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 +3 -3
- package/scripts/e2e-dsh.js +4 -2
- package/src/api.js +1 -1
- package/src/config.js +39 -24
- package/src/dream/decisions.js +33 -63
- package/src/{sleep.js → dream/sleep.js} +118 -29
- 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/sleep.test.js +297 -333
- package/test/summarize.test.js +35 -0
|
@@ -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
|
|
@@ -218,20 +229,21 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
218
229
|
* truncations, and the full body is preserved in _full_content so nothing is
|
|
219
230
|
* lost. Deterministic and cheap, so it runs even with no LLM route.
|
|
220
231
|
*/
|
|
221
|
-
function phaseDemotion(service, config, logger, runId) {
|
|
232
|
+
function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
222
233
|
const archiveDays = config.sleepArchiveDays ?? 30;
|
|
223
|
-
const
|
|
234
|
+
const compressDays = config.sleepCompressDays ?? 90;
|
|
224
235
|
const archiveCut = Date.now() - archiveDays * 86400000;
|
|
225
|
-
const
|
|
236
|
+
const compressCut = Date.now() - compressDays * 86400000;
|
|
226
237
|
const demoted = [];
|
|
227
238
|
const archived = [];
|
|
228
239
|
for (const m of service.all()) {
|
|
240
|
+
if (signal?.aborted) break;
|
|
229
241
|
if (m.archived || m.forgotten) continue;
|
|
230
242
|
const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
|
|
231
243
|
if (!ref) continue;
|
|
232
244
|
const t = new Date(ref).getTime();
|
|
233
245
|
if (Number.isNaN(t)) continue;
|
|
234
|
-
if (t <
|
|
246
|
+
if (t < compressCut) {
|
|
235
247
|
service.setArchived(m.id, true);
|
|
236
248
|
archived.push(m.id);
|
|
237
249
|
} else if (t < archiveCut) {
|
|
@@ -254,20 +266,21 @@ function phaseDemotion(service, config, logger, runId) {
|
|
|
254
266
|
* The empty snapshot is intentional: create claims no existing id, so the
|
|
255
267
|
* "every id claimed" invariant is trivially satisfied for pure-create lists.
|
|
256
268
|
*/
|
|
257
|
-
async function phasePatterns(ctx, service, config, logger, runId) {
|
|
269
|
+
async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
|
|
258
270
|
const route = resolveSleepRoute(ctx, config, logger);
|
|
259
271
|
if (!route) return { status: "skipped", reason: "no llm route" };
|
|
260
|
-
const limit = config.
|
|
272
|
+
const limit = config.sleepPatternMinMemories ?? 100;
|
|
261
273
|
const memories = service
|
|
262
274
|
.list({ limit: 200, includeForgotten: false })
|
|
263
275
|
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
264
276
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
265
277
|
.slice(0, limit);
|
|
266
278
|
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
279
|
+
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
267
280
|
const listText = memories
|
|
268
281
|
.map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
|
|
269
282
|
.join("\n");
|
|
270
|
-
const maxPatterns = config.
|
|
283
|
+
const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
|
|
271
284
|
const text = await streamText(ctx, {
|
|
272
285
|
provider: route.provider,
|
|
273
286
|
model: route.model,
|
|
@@ -305,6 +318,61 @@ async function phasePatterns(ctx, service, config, logger, runId) {
|
|
|
305
318
|
};
|
|
306
319
|
}
|
|
307
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Phase 4 — entity relation completion. Detects orphan entities (zero
|
|
323
|
+
* relations) and completes implied relations from memory co-occurrence:
|
|
324
|
+
* entities named in the same memory → related_to; container kinds
|
|
325
|
+
* (project/module) → part_of; tech-ish pairs → depends_on. Deterministic,
|
|
326
|
+
* no LLM — cheap, so it runs even without a route. saveRelation is
|
|
327
|
+
* bookkeeping (no write hook), so it never re-triggers the scheduler.
|
|
328
|
+
*/
|
|
329
|
+
function inferRelationType(a, b) {
|
|
330
|
+
if ((a.type === "project" || a.type === "module") && a.type !== b.type) return "part_of";
|
|
331
|
+
if ((b.type === "project" || b.type === "module") && b.type !== a.type) return "part_of";
|
|
332
|
+
if (/npm|plugin|api|sdk|lib|framework|package|deps?|build/i.test(`${a.name} ${b.name}`)) return "depends_on";
|
|
333
|
+
return "related_to";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function phaseRelations(service, config, logger, runId, signal = null) {
|
|
337
|
+
const entities = service.listEntities({ limit: 1000 }) ?? [];
|
|
338
|
+
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
339
|
+
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
340
|
+
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
341
|
+
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
342
|
+
const seen = new Set();
|
|
343
|
+
const related = [];
|
|
344
|
+
const MAX_RELATIONS_PER_ORPHAN = 3;
|
|
345
|
+
for (const o of orphans) {
|
|
346
|
+
if (signal?.aborted) break;
|
|
347
|
+
let made = 0;
|
|
348
|
+
for (const m of memories) {
|
|
349
|
+
if (signal?.aborted || made >= MAX_RELATIONS_PER_ORPHAN) break;
|
|
350
|
+
const text = `${m.title ?? ""} ${m.content ?? ""}`;
|
|
351
|
+
if (!text.includes(o.name)) continue;
|
|
352
|
+
for (const other of entities) {
|
|
353
|
+
if (other.id === o.id || other.name === o.name) continue;
|
|
354
|
+
const key = [o.id, other.id].sort().join("|");
|
|
355
|
+
if (seen.has(key)) continue;
|
|
356
|
+
if (!text.includes(other.name)) continue;
|
|
357
|
+
const relationType = inferRelationType(o, other);
|
|
358
|
+
try {
|
|
359
|
+
service.saveRelation({ from_entity: o.id, to_entity: other.id, relation_type: relationType, memory_id: m.id, metadata: { source: "sleep_relation_completion" } });
|
|
360
|
+
seen.add(key);
|
|
361
|
+
related.push({ from: o.id, to: other.id, type: relationType });
|
|
362
|
+
made++;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
logger?.warn?.(`dsh-mneme sleep: relation ${o.id}/${other.id} failed: ${String(error)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
status: related.length > 0 ? "ok" : "noop",
|
|
371
|
+
orphanCount: orphans.length,
|
|
372
|
+
related
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
308
376
|
// ---------------------------------------------------------------- run
|
|
309
377
|
|
|
310
378
|
function deriveStatus(phases) {
|
|
@@ -323,10 +391,11 @@ function deriveStatus(phases) {
|
|
|
323
391
|
* run_type='sleep' audit row (same dream_runs table) so sleep activity is
|
|
324
392
|
* observable alongside consolidation runs.
|
|
325
393
|
*/
|
|
326
|
-
export async function runSleep(ctx, service, config, logger, semantic = null) {
|
|
394
|
+
export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
|
|
327
395
|
const runId = randomUUID();
|
|
328
396
|
const phases = {};
|
|
329
397
|
const attempt = async (name, fn) => {
|
|
398
|
+
if (signal?.aborted) return; // user resumed activity — stop before next phase
|
|
330
399
|
try {
|
|
331
400
|
phases[name] = await fn();
|
|
332
401
|
} catch (error) {
|
|
@@ -334,9 +403,10 @@ export async function runSleep(ctx, service, config, logger, semantic = null) {
|
|
|
334
403
|
logger?.warn?.(`dsh-mneme sleep: ${name} phase failed: ${error?.message ?? error}`);
|
|
335
404
|
}
|
|
336
405
|
};
|
|
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));
|
|
406
|
+
await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
|
|
407
|
+
await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
|
|
408
|
+
await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
|
|
409
|
+
await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
|
|
340
410
|
|
|
341
411
|
const status = deriveStatus(phases);
|
|
342
412
|
const route = resolveSleepRoute(ctx, config, logger);
|
|
@@ -398,11 +468,12 @@ export function createSleepScheduler({
|
|
|
398
468
|
let running = false;
|
|
399
469
|
let disposed = false;
|
|
400
470
|
let idleTimer = null;
|
|
471
|
+
let sleepAbort = null;
|
|
401
472
|
|
|
402
473
|
function armIdleTimer() {
|
|
403
474
|
if (disposed || idleTimer) return;
|
|
404
|
-
if (config.
|
|
405
|
-
const idleMs = (config.sleepIdleMinutes ??
|
|
475
|
+
if (config.sleepModeEnabled !== true) return;
|
|
476
|
+
const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
|
|
406
477
|
const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
|
|
407
478
|
idleTimer = setTimeoutFn(async () => {
|
|
408
479
|
idleTimer = null;
|
|
@@ -413,18 +484,29 @@ export function createSleepScheduler({
|
|
|
413
484
|
|
|
414
485
|
function shouldRun(at = now()) {
|
|
415
486
|
if (disposed || running) return false;
|
|
416
|
-
if (config.
|
|
417
|
-
if (at - lastWriteAt < (config.sleepIdleMinutes ??
|
|
418
|
-
|
|
487
|
+
if (config.sleepModeEnabled !== true) return false;
|
|
488
|
+
if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
|
|
489
|
+
// lastRunAt === 0 means never ran — the min-interval check must not block
|
|
490
|
+
// the very first cycle (a real run stamps a nonzero timestamp).
|
|
491
|
+
if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
|
|
419
492
|
return true;
|
|
420
493
|
}
|
|
421
494
|
|
|
422
495
|
/** Called on writes: resets the idle clock and re-arms the fire timer. The
|
|
423
496
|
* pending timer is cleared first — a stale timer armed against the old idle
|
|
424
497
|
* window would otherwise fire early, fail shouldRun, and leave nothing armed
|
|
425
|
-
* for the next window (a missed trigger until the next write).
|
|
498
|
+
* for the next window (a missed trigger until the next write).
|
|
499
|
+
*
|
|
500
|
+
* While a sleep run is executing (running=true) the in-flight AbortController
|
|
501
|
+
* is NOT aborted: the run's own writes (demoteToSummary / setArchived ride
|
|
502
|
+
* the normal write-hook path) would otherwise self-abort the cycle. External
|
|
503
|
+
* activity during the run still resets the idle clock here, so no new cycle
|
|
504
|
+
* fires until the store is quiet again. */
|
|
426
505
|
function noteWrite() {
|
|
427
506
|
lastWriteAt = now();
|
|
507
|
+
if (!running && sleepAbort) {
|
|
508
|
+
sleepAbort.abort(); // user resumed activity — interrupt an idle run
|
|
509
|
+
}
|
|
428
510
|
if (idleTimer) {
|
|
429
511
|
clearTimeoutFn(idleTimer);
|
|
430
512
|
idleTimer = null;
|
|
@@ -435,16 +517,19 @@ export function createSleepScheduler({
|
|
|
435
517
|
async function maybeSchedule() {
|
|
436
518
|
if (!shouldRun()) return false;
|
|
437
519
|
running = true;
|
|
520
|
+
const abort = new AbortController();
|
|
521
|
+
sleepAbort = abort;
|
|
438
522
|
try {
|
|
439
523
|
lastRunAt = now();
|
|
440
524
|
const result = await service.enqueue(() =>
|
|
441
|
-
onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })
|
|
525
|
+
onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
|
|
442
526
|
);
|
|
443
527
|
return !!(result && result.ok);
|
|
444
528
|
} catch (error) {
|
|
445
529
|
logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
|
|
446
530
|
return false;
|
|
447
531
|
} finally {
|
|
532
|
+
sleepAbort = null;
|
|
448
533
|
running = false;
|
|
449
534
|
}
|
|
450
535
|
}
|
|
@@ -455,6 +540,10 @@ export function createSleepScheduler({
|
|
|
455
540
|
clearTimeoutFn(idleTimer);
|
|
456
541
|
idleTimer = null;
|
|
457
542
|
}
|
|
543
|
+
if (sleepAbort) {
|
|
544
|
+
sleepAbort.abort();
|
|
545
|
+
sleepAbort = null;
|
|
546
|
+
}
|
|
458
547
|
}
|
|
459
548
|
|
|
460
549
|
return { noteWrite, maybeSchedule, shouldRun, dispose };
|
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
|
};
|
package/src/mirror.js
CHANGED
|
@@ -128,21 +128,33 @@ export function createMirror(dir) {
|
|
|
128
128
|
for (const m of memories) {
|
|
129
129
|
(byType[m.type] ??= []).push(m);
|
|
130
130
|
}
|
|
131
|
+
// Per-type physical outcomes (audit peer D): a failed write for one type
|
|
132
|
+
// must not abort the whole render. Each type is written (or pruned) in its
|
|
133
|
+
// own try/catch and the result reported so the caller can persist per-type
|
|
134
|
+
// committed/failed receipts — a file that was already written is a real
|
|
135
|
+
// physical commit even when a sibling type errors.
|
|
136
|
+
const results = {};
|
|
131
137
|
for (const type of Object.keys(TYPE_FILE)) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
try {
|
|
139
|
+
const file = filePath(type);
|
|
140
|
+
const items = (byType[type] ?? [])
|
|
141
|
+
.slice()
|
|
142
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
143
|
+
if (items.length === 0) {
|
|
144
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
145
|
+
// memories do not "resurrect" via readHumanEdits
|
|
146
|
+
rmSync(file, { force: true });
|
|
147
|
+
} else {
|
|
148
|
+
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
149
|
+
const body = items.map(renderMemory).join("\n");
|
|
150
|
+
writeFileSync(file, header + body, "utf8");
|
|
151
|
+
}
|
|
152
|
+
results[type] = { ok: true };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
results[type] = { ok: false, error: error?.message ?? String(error) };
|
|
141
155
|
}
|
|
142
|
-
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
143
|
-
const body = items.map(renderMemory).join("\n");
|
|
144
|
-
writeFileSync(file, header + body, "utf8");
|
|
145
156
|
}
|
|
157
|
+
return results;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
return { filePath, sync, readHumanEdits };
|
package/src/service.js
CHANGED
|
@@ -9,9 +9,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
9
9
|
// passed in the constructor). Fired on the same write events as onWrite.
|
|
10
10
|
let dreamHook = null;
|
|
11
11
|
|
|
12
|
-
// Optional sleep scheduler hook (v0.4.
|
|
13
|
-
// creation. Fired on the same write events: it tells the sleep
|
|
14
|
-
// store just changed so the idle-detection clock resets.
|
|
12
|
+
// Optional sleep scheduler hook (v0.4.0), installed via setSleepHook after
|
|
13
|
+
// creation. Fired on the same write events as onWrite: it tells the sleep
|
|
14
|
+
// scheduler the store just changed so the idle-detection clock resets.
|
|
15
15
|
let sleepHook = null;
|
|
16
16
|
|
|
17
17
|
// Optional vector embedder, installed via setEmbedder after creation. After
|
|
@@ -42,7 +42,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
42
42
|
// replays them exactly once against the committed state.
|
|
43
43
|
let txDepth = 0;
|
|
44
44
|
|
|
45
|
-
// Serial task queue (sleep v0.4.
|
|
45
|
+
// Serial task queue (sleep v0.4.0). Long-running background passes — dream
|
|
46
46
|
// consolidation, sleep cycles — must never overlap: two sleep runs racing
|
|
47
47
|
// would double-demote or double-mint patterns. enqueue chains the task onto
|
|
48
48
|
// a promise tail so N callers can queue work that runs strictly one at a
|
|
@@ -205,7 +205,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
205
205
|
for (const mem of keywordHits) {
|
|
206
206
|
if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
|
|
207
207
|
}
|
|
208
|
-
|
|
208
|
+
const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
|
|
209
|
+
touchRecalled(hits);
|
|
210
|
+
return hits;
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
/**
|
|
@@ -223,7 +225,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
223
225
|
// store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
|
|
224
226
|
// 当前有效记忆(v0.3.0,store.js 已实现)。
|
|
225
227
|
const rows = store.findMemoriesByAttr(key, value ?? "");
|
|
226
|
-
|
|
228
|
+
const hits = rows.slice(0, topK);
|
|
229
|
+
touchRecalled(hits);
|
|
230
|
+
return hits;
|
|
227
231
|
}
|
|
228
232
|
|
|
229
233
|
/**
|
|
@@ -253,6 +257,23 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
253
257
|
return base * (0.5 + (row.importance ?? 3) / 10);
|
|
254
258
|
}
|
|
255
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Sleep touch (v0.4.0): when sleep is enabled, any memory surfaced by recall
|
|
262
|
+
* or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
|
|
263
|
+
* days → demote/archive" tiering counts real access. Best-effort and gated on
|
|
264
|
+
* config.sleepModeEnabled — when sleep is off this is a complete no-op (no
|
|
265
|
+
* writes on the hot recall path). A touch failure must never break search/inject.
|
|
266
|
+
*/
|
|
267
|
+
function touchRecalled(memories) {
|
|
268
|
+
if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
|
|
269
|
+
for (const m of memories) {
|
|
270
|
+
if (!m?.id) continue;
|
|
271
|
+
try {
|
|
272
|
+
store.touchLastAccess(m.id);
|
|
273
|
+
} catch { /* touch is best effort */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
async function searchMemories(query, options = {}) {
|
|
257
278
|
const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
|
|
258
279
|
const q = String(query ?? "").trim();
|
|
@@ -261,15 +282,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
261
282
|
// entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
|
|
262
283
|
if (config?.entitySearchEnabled) {
|
|
263
284
|
if (q.startsWith("entity:")) {
|
|
264
|
-
|
|
265
|
-
touchRecalled(hits);
|
|
266
|
-
return hits;
|
|
285
|
+
return searchByEntity(q.slice(7).trim(), options);
|
|
267
286
|
}
|
|
268
287
|
if (q.startsWith("attr:")) {
|
|
269
288
|
const [key, value] = q.slice(5).split("=");
|
|
270
|
-
|
|
271
|
-
touchRecalled(hits);
|
|
272
|
-
return hits;
|
|
289
|
+
return searchByAttr(key, value, options);
|
|
273
290
|
}
|
|
274
291
|
}
|
|
275
292
|
|
|
@@ -456,23 +473,6 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
456
473
|
return { action: "created", memory: created };
|
|
457
474
|
}
|
|
458
475
|
|
|
459
|
-
/**
|
|
460
|
-
* Sleep touch (v0.4.1): when sleep is enabled, any memory surfaced by recall
|
|
461
|
-
* or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
|
|
462
|
-
* days → demote/archive" tiering counts real access. Best-effort and gated on
|
|
463
|
-
* config.sleepEnabled — when sleep is off this is a complete no-op (no writes
|
|
464
|
-
* on the hot recall path). A touch failure must never break search/inject.
|
|
465
|
-
*/
|
|
466
|
-
function touchRecalled(memories) {
|
|
467
|
-
if (config?.sleepEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
|
|
468
|
-
for (const m of memories) {
|
|
469
|
-
if (!m?.id) continue;
|
|
470
|
-
try {
|
|
471
|
-
store.touchAccess(m.id);
|
|
472
|
-
} catch { /* touch is best effort */ }
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
476
|
/**
|
|
477
477
|
* Candidate memories for automatic context injection:
|
|
478
478
|
* summaries first, then all preferences, then non-forgotten items with
|
|
@@ -648,26 +648,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
648
648
|
}
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
651
|
+
// Per-type physical outcome (audit peer D): mirror.sync writes each type
|
|
652
|
+
// file independently and reports per-type success/failure. A type whose
|
|
653
|
+
// file was physically committed must be marked committed even when a
|
|
654
|
+
// sibling type errors — the old code batch-failed every type on any error,
|
|
655
|
+
// leaving committed files mislabeled as failed and masking partial state.
|
|
656
|
+
// Absent entries (a type with no memories) count as success: sync prunes
|
|
657
|
+
// the stale file, which is itself a completed physical state.
|
|
658
|
+
let allOk = true;
|
|
659
|
+
const results = mirror.sync(reconcileHumanEdits(list)) ?? {};
|
|
660
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
661
|
+
const r = results[type];
|
|
662
|
+
const ok = !r || r.ok === true;
|
|
663
|
+
if (!ok) allOk = false;
|
|
664
|
+
try {
|
|
665
|
+
if (ok) {
|
|
666
|
+
store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
|
|
667
|
+
} else {
|
|
668
|
+
store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
|
|
669
|
+
}
|
|
670
|
+
} catch (stateError) {
|
|
671
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
|
|
672
|
+
}
|
|
661
673
|
}
|
|
662
|
-
|
|
663
|
-
|
|
674
|
+
|
|
675
|
+
// 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
|
|
676
|
+
// 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
677
|
+
if (allOk) {
|
|
664
678
|
try {
|
|
665
|
-
store.
|
|
679
|
+
store.markMirrorCleanForGeneration(gen, now);
|
|
666
680
|
} catch (stateError) {
|
|
667
|
-
logger?.warn?.(
|
|
681
|
+
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
682
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
668
683
|
}
|
|
684
|
+
return { success: true };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
|
|
688
|
+
// 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
|
|
689
|
+
const failedTypes = Object.entries(results)
|
|
690
|
+
.filter(([, r]) => r && r.ok === false)
|
|
691
|
+
.map(([t]) => t);
|
|
692
|
+
try {
|
|
693
|
+
store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
|
|
694
|
+
} catch (stateError) {
|
|
695
|
+
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
669
696
|
}
|
|
670
|
-
return { success:
|
|
697
|
+
return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
|
|
671
698
|
} catch (error) {
|
|
672
699
|
const errMsg = error?.message ?? String(error);
|
|
673
700
|
logger?.warn?.("syncMirror failed:", error);
|
|
@@ -691,14 +718,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
691
718
|
}
|
|
692
719
|
|
|
693
720
|
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
694
|
-
// swallowing it (peer blocker 2). The mirror debt has already
|
|
695
|
-
// by markMirrorDirty inside syncMirror, so a restart recovers —
|
|
696
|
-
// calling write path must not report clean while the mirror is
|
|
721
|
+
// swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
|
|
722
|
+
// been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
|
|
723
|
+
// but the calling write path must not report clean while the mirror is
|
|
724
|
+
// known-stale. Returns the sync result so the caller can attach an explicit
|
|
725
|
+
// degraded/pending receipt to its return value instead of faking success.
|
|
697
726
|
function afterSync(label) {
|
|
698
727
|
const r = syncMirror();
|
|
699
728
|
if (!r?.success && !r?.deferred) {
|
|
700
729
|
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
701
730
|
}
|
|
731
|
+
return r;
|
|
702
732
|
}
|
|
703
733
|
|
|
704
734
|
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
@@ -873,9 +903,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
873
903
|
memory_id: id
|
|
874
904
|
});
|
|
875
905
|
}
|
|
876
|
-
afterSync("write");
|
|
906
|
+
const sync = afterSync("write");
|
|
877
907
|
notifyWrite();
|
|
878
908
|
scheduleEmbed(updated);
|
|
909
|
+
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
910
|
+
// the mirror did not converge — return an explicit degraded receipt rather
|
|
911
|
+
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
912
|
+
// the memory shape keep passing.
|
|
913
|
+
if (!sync?.success && !sync?.deferred) {
|
|
914
|
+
Object.defineProperty(updated, "_mirror", {
|
|
915
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
916
|
+
enumerable: false,
|
|
917
|
+
configurable: true
|
|
918
|
+
});
|
|
919
|
+
}
|
|
879
920
|
return updated;
|
|
880
921
|
},
|
|
881
922
|
// Compare-and-set update: applies the patch only when the row still carries
|
|
@@ -902,9 +943,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
902
943
|
memory_id: id
|
|
903
944
|
});
|
|
904
945
|
}
|
|
905
|
-
afterSync("write");
|
|
946
|
+
const sync = afterSync("write");
|
|
906
947
|
notifyWrite();
|
|
907
948
|
scheduleEmbed(updated);
|
|
949
|
+
// Audit peer B: mirror sync failure on a CAS write must surface too.
|
|
950
|
+
if (!sync?.success && !sync?.deferred) {
|
|
951
|
+
Object.defineProperty(updated, "_mirror", {
|
|
952
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
953
|
+
enumerable: false,
|
|
954
|
+
configurable: true
|
|
955
|
+
});
|
|
956
|
+
}
|
|
908
957
|
return updated;
|
|
909
958
|
},
|
|
910
959
|
setForget: (id, f) => {
|
|
@@ -917,11 +966,22 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
917
966
|
afterSync("write");
|
|
918
967
|
return updated;
|
|
919
968
|
},
|
|
969
|
+
// sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
|
|
970
|
+
// content so they ride the normal write-hook path (mirror re-renders).
|
|
971
|
+
// touchLastAccess is a read-stamp — deliberately NO write hook (a recall
|
|
972
|
+
// must not dirty the mirror). getUnrecalledSince is a pure read.
|
|
920
973
|
demoteToSummary: (id, summary, opts) => {
|
|
921
974
|
const updated = store.demoteToSummary(id, summary, opts);
|
|
922
975
|
afterSync("write");
|
|
923
976
|
return updated;
|
|
924
977
|
},
|
|
978
|
+
restoreContent: (id) => {
|
|
979
|
+
const updated = store.restoreContent(id);
|
|
980
|
+
afterSync("write");
|
|
981
|
+
return updated;
|
|
982
|
+
},
|
|
983
|
+
touchLastAccess: (id, at) => store.touchLastAccess(id, at),
|
|
984
|
+
getUnrecalledSince: (cutMs, opts) => store.getUnrecalledSince(cutMs, opts),
|
|
925
985
|
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|
|
926
986
|
// an audit write is bookkeeping, and notifyWrite would loop back into the
|
|
927
987
|
// dream scheduler that just recorded the run.
|
|
@@ -944,6 +1004,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
944
1004
|
// migrates entity_attrs on merge. Bookkeeping writes like the audit
|
|
945
1005
|
// passthroughs above — never write-hook-triggering memory mutations.
|
|
946
1006
|
saveRelation: (r) => store.saveRelation(r),
|
|
1007
|
+
listEntities: (o) => store.listEntities(o),
|
|
1008
|
+
getRelations: (id) => store.getRelations(id),
|
|
1009
|
+
saveAttr: (r) => store.saveAttr(r),
|
|
1010
|
+
createEntity: (r) => store.createEntity(r),
|
|
1011
|
+
findEntityByName: (n) => store.findEntityByName(n),
|
|
1012
|
+
findEntityById: (id) => store.findEntityById(id),
|
|
947
1013
|
getAttrsByMemory: (id) => store.getAttrsByMemory(id),
|
|
948
1014
|
migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
|
|
949
1015
|
};
|