@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
package/lib/store.js
CHANGED
|
@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
13
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
14
|
source TEXT,
|
|
15
15
|
embedding TEXT,
|
|
16
|
+
last_accessed_at TEXT,
|
|
17
|
+
_full_content TEXT,
|
|
16
18
|
created_at TEXT NOT NULL,
|
|
17
19
|
updated_at TEXT NOT NULL
|
|
18
20
|
);
|
|
@@ -38,7 +40,8 @@ CREATE TABLE IF NOT EXISTS dream_runs (
|
|
|
38
40
|
applied INTEGER NOT NULL DEFAULT 0,
|
|
39
41
|
summary_stored INTEGER NOT NULL DEFAULT 0,
|
|
40
42
|
receipt TEXT NOT NULL,
|
|
41
|
-
policy_epoch INTEGER NOT NULL DEFAULT 0 -- 裁决规则版本:规则升级后旧裁决降级为历史证据
|
|
43
|
+
policy_epoch INTEGER NOT NULL DEFAULT 0, -- 裁决规则版本:规则升级后旧裁决降级为历史证据
|
|
44
|
+
run_type TEXT NOT NULL DEFAULT 'auto' -- auto | sleep:睡眠周期的审计区分
|
|
42
45
|
);
|
|
43
46
|
CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
|
|
44
47
|
|
|
@@ -180,8 +183,8 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
180
183
|
last_error TEXT, -- 最近失败原因
|
|
181
184
|
last_attempt TEXT, -- 最近尝试时间(ISO)
|
|
182
185
|
success_at TEXT, -- 最近成功时间(ISO)
|
|
183
|
-
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991), -- 期望的同步轮次(desired)
|
|
184
|
-
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991), -- 已成功应用的轮次
|
|
186
|
+
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
|
|
187
|
+
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
|
|
185
188
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
189
|
);
|
|
187
190
|
`;
|
|
@@ -228,9 +231,6 @@ function toRow(row) {
|
|
|
228
231
|
source: row.source ?? undefined,
|
|
229
232
|
created_at: row.created_at,
|
|
230
233
|
updated_at: row.updated_at,
|
|
231
|
-
// Sleep (v0.4.1): last_accessed_at drives the unrecalled tiering;
|
|
232
|
-
// _full_content holds the pre-demotion body for a memory reduced to its
|
|
233
|
-
// summary. Both are internal — the mirror render must not expose them.
|
|
234
234
|
last_accessed_at: row.last_accessed_at ?? undefined,
|
|
235
235
|
_full_content: row._full_content ?? undefined
|
|
236
236
|
};
|
|
@@ -397,11 +397,14 @@ function parseJsonArray(raw) {
|
|
|
397
397
|
|
|
398
398
|
export function createStore(path) {
|
|
399
399
|
const db = new DatabaseSync(path);
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
400
|
+
// Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
|
|
401
|
+
// init). Switching a fresh DB to WAL takes an exclusive lock; when several
|
|
402
|
+
// processes open the same path simultaneously, that lock can fail with
|
|
403
|
+
// SQLITE_BUSY before the timeout is armed. With the timeout installed first,
|
|
404
|
+
// the WAL transition (and every later write) blocks and retries instead of
|
|
405
|
+
// failing outright, so concurrent init converges to a stable 447/447.
|
|
404
406
|
db.exec("PRAGMA busy_timeout = 5000;");
|
|
407
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
405
408
|
db.exec(SCHEMA);
|
|
406
409
|
|
|
407
410
|
// Schema migrations for legacy databases (idempotent).
|
|
@@ -412,6 +415,12 @@ export function createStore(path) {
|
|
|
412
415
|
if (!columns.includes("embedding")) {
|
|
413
416
|
db.exec("ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
414
417
|
}
|
|
418
|
+
if (!columns.includes("last_accessed_at")) {
|
|
419
|
+
db.exec("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
420
|
+
}
|
|
421
|
+
if (!columns.includes("_full_content")) {
|
|
422
|
+
db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
423
|
+
}
|
|
415
424
|
|
|
416
425
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
417
426
|
const dreamCols = db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
|
|
@@ -422,16 +431,6 @@ export function createStore(path) {
|
|
|
422
431
|
db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
423
432
|
}
|
|
424
433
|
|
|
425
|
-
// Sleep (v0.4.1) columns: last_accessed_at tracks recall/inject touch for the
|
|
426
|
-
// "unrecalled N days → demote/archive" tiering; _full_content holds the pre-demotion
|
|
427
|
-
// body when a memory is reduced to its summary. Both are additive-only.
|
|
428
|
-
if (!columns.includes("last_accessed_at")) {
|
|
429
|
-
db.exec("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
430
|
-
}
|
|
431
|
-
if (!columns.includes("_full_content")) {
|
|
432
|
-
db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
433
|
-
}
|
|
434
|
-
|
|
435
434
|
// Legacy mirror_state without v0.3.6 generation columns → add each missing
|
|
436
435
|
// column idempotently (old DBs open cleanly, no data loss).
|
|
437
436
|
const mirrorCols = db.prepare("PRAGMA table_info(mirror_state)").all().map((c) => c.name);
|
|
@@ -445,6 +444,24 @@ export function createStore(path) {
|
|
|
445
444
|
db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
446
445
|
}
|
|
447
446
|
|
|
447
|
+
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
448
|
+
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
449
|
+
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
450
|
+
// round — so surface it as a hard error on open instead of silently reading it
|
|
451
|
+
// as a coherent generation. Fail-closed: the operator must repair or reset the
|
|
452
|
+
// state row rather than continue with a lie.
|
|
453
|
+
for (const col of ["generation", "applied_generation"]) {
|
|
454
|
+
const bad = db.prepare(
|
|
455
|
+
`SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
|
|
456
|
+
).get();
|
|
457
|
+
if (bad) {
|
|
458
|
+
throw new RangeError(
|
|
459
|
+
`mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
|
|
460
|
+
`repair or reset the row before opening this database`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
448
465
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
449
466
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
450
467
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -571,24 +588,35 @@ export function createStore(path) {
|
|
|
571
588
|
const embedding = patch.embedding !== undefined
|
|
572
589
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
573
590
|
: existing.embedding ?? null;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
591
|
+
// The CAS UPDATE and the desired-generation bump must commit together (audit
|
|
592
|
+
// peer A): if the UPDATE autocommits first and the process dies before the
|
|
593
|
+
// increment, the store is mutated while generation == applied_generation and
|
|
594
|
+
// dirty == false — recoverMirror sees no debt and the mirror stays stale.
|
|
595
|
+
// Wrapping both in one transaction means a CAS miss rolls back cleanly too
|
|
596
|
+
// (no write, no generation bump).
|
|
597
|
+
let applied = false;
|
|
598
|
+
runAtomically(() => {
|
|
599
|
+
const result = db.prepare(
|
|
600
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
|
|
601
|
+
WHERE id=? AND updated_at=?`
|
|
602
|
+
).run(
|
|
603
|
+
type,
|
|
604
|
+
patch.title ?? existing.title,
|
|
605
|
+
patch.content ?? existing.content,
|
|
606
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
607
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
608
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
609
|
+
embedding,
|
|
610
|
+
now,
|
|
611
|
+
id,
|
|
612
|
+
expectedUpdatedAt
|
|
613
|
+
);
|
|
614
|
+
if (result.changes === 0) return; // CAS miss: a concurrent write won
|
|
615
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
616
|
+
incrementGeneration();
|
|
617
|
+
applied = true;
|
|
618
|
+
});
|
|
619
|
+
if (!applied) return undefined;
|
|
592
620
|
return getById(id);
|
|
593
621
|
}
|
|
594
622
|
|
|
@@ -610,46 +638,67 @@ export function createStore(path) {
|
|
|
610
638
|
return getById(id);
|
|
611
639
|
}
|
|
612
640
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
* generation because content visibly changes in the mirror file.
|
|
629
|
-
*
|
|
630
|
-
* minRefTimeMs (optional): the sleep tiering's freshness cutoff. A memory
|
|
631
|
-
* whose reference time (last_accessed_at ?? updated_at ?? created_at) is
|
|
632
|
-
* newer than the cutoff is skipped — phaseDemotion snapshots ref times up
|
|
633
|
-
* front, and a recall touch landing between the snapshot and this call must
|
|
634
|
-
* not demote a freshly-accessed memory. The check and the update run in the
|
|
635
|
-
* same synchronous transaction, so the read-then-write is atomic. */
|
|
641
|
+
// --- sleep-mode storage support (v0.4.0) ---------------------------------
|
|
642
|
+
// touchLastAccess stamps the read time on recall/inject paths. It deliberately
|
|
643
|
+
// does NOT bump the mirror generation: reads must not mark the mirror dirty.
|
|
644
|
+
function touchLastAccess(id, at) {
|
|
645
|
+
if (!getById(id)) return false;
|
|
646
|
+
db.prepare("UPDATE memories SET last_accessed_at = ? WHERE id = ?")
|
|
647
|
+
.run(at ?? nowIso(), id);
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Shrink an aged memory to `summary`, parking its full body in _full_content.
|
|
652
|
+
// Idempotent: an already-demoted memory (non-null _full_content) is left
|
|
653
|
+
// untouched. minRefTimeMs guards the fast path — if last_accessed_at moved
|
|
654
|
+
// after the caller's snapshot (>= minRefTimeMs), the memory is hot again and
|
|
655
|
+
// is skipped. Returns the updated memory, or undefined when skipped/absent.
|
|
636
656
|
function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
|
|
657
|
+
let changed = false;
|
|
637
658
|
runAtomically(() => {
|
|
638
|
-
const row = db.prepare(
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const ref = row.last_accessed_at ?? row.updated_at ?? row.created_at;
|
|
644
|
-
const t = ref ? new Date(ref).getTime() : NaN;
|
|
645
|
-
if (!Number.isNaN(t) && t >= minRefTimeMs) return; // freshly touched: keep full
|
|
659
|
+
const row = db.prepare("SELECT last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
|
|
660
|
+
if (!row || row._full_content) return;
|
|
661
|
+
if (minRefTimeMs !== undefined && row.last_accessed_at) {
|
|
662
|
+
const lastMs = Date.parse(row.last_accessed_at);
|
|
663
|
+
if (lastMs >= minRefTimeMs) return; // touched after snapshot — still hot
|
|
646
664
|
}
|
|
647
665
|
db.prepare(
|
|
648
|
-
"UPDATE memories SET
|
|
649
|
-
).run(row.content
|
|
666
|
+
"UPDATE memories SET content = ?, _full_content = ?, updated_at = ? WHERE id = ?"
|
|
667
|
+
).run(summary, row.content, nowIso(), id);
|
|
650
668
|
incrementGeneration();
|
|
669
|
+
changed = true;
|
|
651
670
|
});
|
|
652
|
-
return getById(id);
|
|
671
|
+
return changed ? getById(id) : undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Undo demoteToSummary: pull the parked body back into content.
|
|
675
|
+
function restoreContent(id) {
|
|
676
|
+
let changed = false;
|
|
677
|
+
runAtomically(() => {
|
|
678
|
+
const row = db.prepare("SELECT content, _full_content FROM memories WHERE id = ?").get(id);
|
|
679
|
+
if (!row || !row._full_content) return;
|
|
680
|
+
db.prepare(
|
|
681
|
+
"UPDATE memories SET content = ?, _full_content = NULL, updated_at = ? WHERE id = ?"
|
|
682
|
+
).run(row._full_content, nowIso(), id);
|
|
683
|
+
incrementGeneration();
|
|
684
|
+
changed = true;
|
|
685
|
+
});
|
|
686
|
+
return changed ? getById(id) : undefined;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Live memories that have not been touched since `cutMs` (never-touched ones
|
|
690
|
+
// fall back to created_at). Ordered by last access ascending — the coldest
|
|
691
|
+
// first. Used by sleep phase 2 to pick archival-demotion candidates.
|
|
692
|
+
function getUnrecalledSince(cutMs, { limit = 500 } = {}) {
|
|
693
|
+
const cutIso = new Date(cutMs).toISOString();
|
|
694
|
+
const rows = db.prepare(
|
|
695
|
+
`SELECT * FROM memories
|
|
696
|
+
WHERE forgotten = 0 AND archived = 0
|
|
697
|
+
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
698
|
+
ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
|
|
699
|
+
LIMIT ?`
|
|
700
|
+
).all(cutIso, limit);
|
|
701
|
+
return rows.map(toRow);
|
|
653
702
|
}
|
|
654
703
|
|
|
655
704
|
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
|
|
@@ -1246,6 +1295,16 @@ export function createStore(path) {
|
|
|
1246
1295
|
).all(entityId, entityId).map(toRelation);
|
|
1247
1296
|
}
|
|
1248
1297
|
|
|
1298
|
+
/** All entities (optionally name-filtered, newest first). Used by sleep phase 4
|
|
1299
|
+
* orphan detection: an entity with zero relations is a candidate for relation
|
|
1300
|
+
* completion. */
|
|
1301
|
+
function listEntities({ limit = 1000 } = {}) {
|
|
1302
|
+
const rows = db.prepare(
|
|
1303
|
+
"SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
|
|
1304
|
+
).all(limit);
|
|
1305
|
+
return rows.map(toEntity);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1249
1308
|
// --- mirror sync state (F-NEW-03) -----------------------------------------
|
|
1250
1309
|
|
|
1251
1310
|
/**
|
|
@@ -1286,8 +1345,15 @@ export function createStore(path) {
|
|
|
1286
1345
|
if (key === "dirty") {
|
|
1287
1346
|
value = value ? 1 : 0;
|
|
1288
1347
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1289
|
-
|
|
1290
|
-
|
|
1348
|
+
// Fail-closed integer enforcement (audit peer F): never truncate. A
|
|
1349
|
+
// fractional value like 1.5 previously passed the JS gate via
|
|
1350
|
+
// Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
|
|
1351
|
+
// dirty legacy row could carry a non-integer generation that reads as a
|
|
1352
|
+
// coherent applied round. Reject non-integers outright — the caller must
|
|
1353
|
+
// pass a whole number, and a stale dirty value stays visible instead of
|
|
1354
|
+
// being "repaired" into a misleading clean integer.
|
|
1355
|
+
value = Number(value);
|
|
1356
|
+
if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1291
1357
|
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1292
1358
|
}
|
|
1293
1359
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
@@ -1437,8 +1503,10 @@ export function createStore(path) {
|
|
|
1437
1503
|
remove,
|
|
1438
1504
|
setForget,
|
|
1439
1505
|
setArchived,
|
|
1440
|
-
|
|
1506
|
+
touchLastAccess,
|
|
1441
1507
|
demoteToSummary,
|
|
1508
|
+
restoreContent,
|
|
1509
|
+
getUnrecalledSince,
|
|
1442
1510
|
list,
|
|
1443
1511
|
all,
|
|
1444
1512
|
search,
|
|
@@ -1467,6 +1535,7 @@ export function createStore(path) {
|
|
|
1467
1535
|
createEntity,
|
|
1468
1536
|
findEntityByName,
|
|
1469
1537
|
findEntityById,
|
|
1538
|
+
listEntities,
|
|
1470
1539
|
updateEntity,
|
|
1471
1540
|
saveAttr,
|
|
1472
1541
|
invalidateOldAttr,
|
package/lib/summarize.js
CHANGED
|
@@ -100,9 +100,12 @@ export function createSummarizer(ctx, service, config) {
|
|
|
100
100
|
inFlight.set(session.id, controller);
|
|
101
101
|
try {
|
|
102
102
|
const header = session.requestHeader?.()?.config;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
:
|
|
103
|
+
// Config override takes priority, then session header, then nothing.
|
|
104
|
+
const route = (config.summarizeProvider && config.summarizeModel)
|
|
105
|
+
? { provider: config.summarizeProvider, model: config.summarizeModel }
|
|
106
|
+
: (header?.provider && header?.model)
|
|
107
|
+
? { provider: header.provider, model: header.model }
|
|
108
|
+
: undefined;
|
|
106
109
|
if (!route) return;
|
|
107
110
|
const messages = collectMessages(session);
|
|
108
111
|
if (!messages.length) return;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
|
-
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors,
|
|
4
|
-
"version": "0.4.
|
|
3
|
+
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
+
"version": "0.4.2",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -64,4 +64,4 @@
|
|
|
64
64
|
"overrides": {
|
|
65
65
|
"adm-zip": "0.6.0"
|
|
66
66
|
}
|
|
67
|
-
}
|
|
67
|
+
}
|
package/scripts/e2e-dsh.js
CHANGED
|
@@ -104,9 +104,11 @@ console.log(`记忆目录:${memDir}\n`);
|
|
|
104
104
|
// 1. 装载检查
|
|
105
105
|
console.log("【1】插件装载");
|
|
106
106
|
const checks = [];
|
|
107
|
-
checks.push(["注册
|
|
107
|
+
checks.push(["注册 7 个模型工具", registeredTools.length === 7]);
|
|
108
108
|
checks.push(["注册 2 个注入上下文", injectContexts.length === 2 && injectContexts[0].name === "memory"]);
|
|
109
|
-
|
|
109
|
+
// 契约是 9 条 exact 路由;prefix fallback(/api/dsh-mneme → 404)是兜底,
|
|
110
|
+
// 不计入路由数。
|
|
111
|
+
checks.push(["注册 9 条 API 路由", apiRoutes.filter((r) => r.kind === "exact").length === 9]);
|
|
110
112
|
for (const [label, ok] of checks) console.log(` ${ok ? "✅" : "❌"} ${label}`);
|
|
111
113
|
if (!checks.every(([, ok]) => ok)) { console.log("\n装载检查失败,中止。"); process.exit(1); }
|
|
112
114
|
console.log(` 工具:${registeredTools.map((t) => t.name).join(", ")}\n`);
|
package/src/api.js
CHANGED
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),
|
|
@@ -92,31 +97,41 @@ export const Config = z.object({
|
|
|
92
97
|
// Prefix/semantic search over entity names (used by recall).
|
|
93
98
|
entitySearchEnabled: z.boolean().default(true),
|
|
94
99
|
|
|
95
|
-
// ---
|
|
96
|
-
// Opt-in
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
100
|
+
// --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
|
|
101
|
+
// Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
|
|
102
|
+
// sleep fires when the store has been quiet for sleepIdleMinutes and deep-
|
|
103
|
+
// maintains the whole library: conflict resolution, archival demotion,
|
|
104
|
+
// pattern discovery and entity relation completion. Abortable on user
|
|
105
|
+
// activity, audited into dream_runs (run_type='sleep'), and serialized with
|
|
106
|
+
// autoDream so the two never overlap.
|
|
107
|
+
sleepModeEnabled: z.boolean().default(false),
|
|
108
|
+
// Quiet window before a cycle fires (minutes).
|
|
109
|
+
sleepIdleMinutes: z.natural().min(1).max(60).default(5),
|
|
110
|
+
// Minimum gap between two sleep runs (hours) — a second idle window within
|
|
111
|
+
// this interval does not retrigger.
|
|
107
112
|
sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
113
|
+
// Conflict adjudication strictness:
|
|
114
|
+
// gentle only high-confidence conflicts (threshold 0.92) are resolved
|
|
115
|
+
// normal standard dream-level (threshold 0.85)
|
|
116
|
+
// aggressive low-confidence pairs are also adjudicated (threshold 0.75)
|
|
117
|
+
sleepConflictStrictness: z.union([
|
|
118
|
+
z.const("gentle"),
|
|
119
|
+
z.const("normal"),
|
|
120
|
+
z.const("aggressive")
|
|
121
|
+
]).default("normal"),
|
|
122
|
+
// Archival demotion tiering (days since last access):
|
|
123
|
+
// >= sleepArchiveDays → shrink to summary, full body kept in _full_content
|
|
124
|
+
// >= sleepCompressDays → archived outright (entity relations preserved)
|
|
125
|
+
sleepArchiveDays: z.natural().min(7).max(365).default(30),
|
|
126
|
+
sleepCompressDays: z.natural().min(7).max(365).default(90),
|
|
127
|
+
// Pattern discovery scan window (most recent memories to scan).
|
|
128
|
+
sleepPatternMinMemories: z.natural().min(10).max(1000).default(100),
|
|
129
|
+
// How far back pattern discovery considers entity attr changes (days).
|
|
130
|
+
sleepPatternLookbackDays: z.natural().min(1).max(90).default(30),
|
|
131
|
+
// Max pattern memories minted per run (0 = disabled).
|
|
132
|
+
sleepMaxPatternPerRun: z.natural().min(0).max(10).default(3),
|
|
133
|
+
// Optional LLM route override for sleep's bulk passes (empty = use dream
|
|
134
|
+
// route / agent default model).
|
|
120
135
|
sleepProvider: z.string().default(""),
|
|
121
136
|
sleepModel: z.string().default(""),
|
|
122
137
|
});
|
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) {
|