@modusensus/dsh-mneme 0.3.7 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -3
- package/lib/api.js +8 -0
- package/lib/config.js +28 -0
- package/lib/dream/decisions.js +79 -1
- package/lib/index.js +21 -0
- package/lib/service.js +103 -31
- package/lib/sleep.js +461 -0
- package/lib/store.js +186 -41
- package/package.json +1 -1
- package/src/api.js +8 -0
- package/src/config.js +28 -0
- package/src/dream/decisions.js +79 -1
- package/src/index.js +21 -0
- package/src/service.js +103 -31
- package/src/sleep.js +461 -0
- package/src/store.js +186 -41
- package/test/mirror-generation.test.js +24 -21
- package/test/peer-blockers.test.js +148 -0
- package/test/sleep.test.js +401 -0
package/lib/store.js
CHANGED
|
@@ -180,13 +180,18 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
180
180
|
last_error TEXT, -- 最近失败原因
|
|
181
181
|
last_attempt TEXT, -- 最近尝试时间(ISO)
|
|
182
182
|
success_at TEXT, -- 最近成功时间(ISO)
|
|
183
|
-
generation INTEGER NOT NULL DEFAULT 0, -- 期望的同步轮次(desired)
|
|
184
|
-
applied_generation INTEGER NOT NULL DEFAULT 0, -- 已成功应用的轮次
|
|
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), -- 已成功应用的轮次
|
|
185
185
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
186
|
);
|
|
187
187
|
`;
|
|
188
188
|
|
|
189
|
-
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
189
|
+
const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
|
|
190
|
+
|
|
191
|
+
// Per-type mirror sync receipts (peer blocker 4): a type is either committed
|
|
192
|
+
// (file written + fence applied), failed (last sync round errored for it), or
|
|
193
|
+
// pending (still owed a write).
|
|
194
|
+
const VALID_TYPE_STATUS = new Set(["committed", "failed", "pending"]);
|
|
190
195
|
|
|
191
196
|
// Pure helpers: no shared module state.
|
|
192
197
|
|
|
@@ -222,7 +227,12 @@ function toRow(row) {
|
|
|
222
227
|
archived: row.archived === 1,
|
|
223
228
|
source: row.source ?? undefined,
|
|
224
229
|
created_at: row.created_at,
|
|
225
|
-
updated_at: row.updated_at
|
|
230
|
+
updated_at: row.updated_at,
|
|
231
|
+
// Sleep (v0.4.1): last_accessed_at drives the unrecalled tiering;
|
|
232
|
+
// _full_content holds the pre-demotion body for a memory reduced to its
|
|
233
|
+
// summary. Both are internal — the mirror render must not expose them.
|
|
234
|
+
last_accessed_at: row.last_accessed_at ?? undefined,
|
|
235
|
+
_full_content: row._full_content ?? undefined
|
|
226
236
|
};
|
|
227
237
|
}
|
|
228
238
|
|
|
@@ -243,7 +253,8 @@ function toDreamRun(row) {
|
|
|
243
253
|
applied: row.applied,
|
|
244
254
|
summary_stored: row.summary_stored === 1,
|
|
245
255
|
receipt: row.receipt,
|
|
246
|
-
policy_epoch: row.policy_epoch ?? 0
|
|
256
|
+
policy_epoch: row.policy_epoch ?? 0,
|
|
257
|
+
run_type: row.run_type ?? "auto"
|
|
247
258
|
};
|
|
248
259
|
}
|
|
249
260
|
|
|
@@ -387,6 +398,10 @@ function parseJsonArray(raw) {
|
|
|
387
398
|
export function createStore(path) {
|
|
388
399
|
const db = new DatabaseSync(path);
|
|
389
400
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
401
|
+
// Concurrent writers (peer probe: 8 independent processes) must wait for the
|
|
402
|
+
// write lock instead of failing immediately with SQLITE_BUSY — otherwise the
|
|
403
|
+
// atomic generation increment loses whole writes, not just increments.
|
|
404
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
390
405
|
db.exec(SCHEMA);
|
|
391
406
|
|
|
392
407
|
// Schema migrations for legacy databases (idempotent).
|
|
@@ -403,6 +418,19 @@ export function createStore(path) {
|
|
|
403
418
|
if (!dreamCols.includes("policy_epoch")) {
|
|
404
419
|
db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
405
420
|
}
|
|
421
|
+
if (!dreamCols.includes("run_type")) {
|
|
422
|
+
db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Sleep (v0.4.1) columns: last_accessed_at tracks recall/inject touch for the
|
|
426
|
+
// "unrecalled N days → demote/archive" tiering; _full_content holds the pre-demotion
|
|
427
|
+
// body when a memory is reduced to its summary. Both are additive-only.
|
|
428
|
+
if (!columns.includes("last_accessed_at")) {
|
|
429
|
+
db.exec("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
430
|
+
}
|
|
431
|
+
if (!columns.includes("_full_content")) {
|
|
432
|
+
db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
433
|
+
}
|
|
406
434
|
|
|
407
435
|
// Legacy mirror_state without v0.3.6 generation columns → add each missing
|
|
408
436
|
// column idempotently (old DBs open cleanly, no data loss).
|
|
@@ -467,10 +495,17 @@ export function createStore(path) {
|
|
|
467
495
|
const embedding = Array.isArray(memory.embedding) && memory.embedding.length
|
|
468
496
|
? JSON.stringify(memory.embedding)
|
|
469
497
|
: null;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
498
|
+
runAtomically(() => {
|
|
499
|
+
db.prepare(
|
|
500
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
|
|
501
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
|
|
502
|
+
).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
|
|
503
|
+
// desired generation bumped in the same transaction as the write: once
|
|
504
|
+
// this commits, generation > applied_generation, so a crash right after
|
|
505
|
+
// (before syncMirror) is caught by recoverMirror on restart (peer
|
|
506
|
+
// blocker 1). ROLLBACK on error rolls this back with the write.
|
|
507
|
+
incrementGeneration();
|
|
508
|
+
});
|
|
474
509
|
return getById(id);
|
|
475
510
|
}
|
|
476
511
|
|
|
@@ -486,24 +521,34 @@ export function createStore(path) {
|
|
|
486
521
|
const embedding = patch.embedding !== undefined
|
|
487
522
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
488
523
|
: existing.embedding ?? null;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
524
|
+
runAtomically(() => {
|
|
525
|
+
db.prepare(
|
|
526
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
|
|
527
|
+
).run(
|
|
528
|
+
type,
|
|
529
|
+
patch.title ?? existing.title,
|
|
530
|
+
patch.content ?? existing.content,
|
|
531
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
532
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
533
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
534
|
+
embedding,
|
|
535
|
+
now,
|
|
536
|
+
id
|
|
537
|
+
);
|
|
538
|
+
// Desired generation bumped in the same transaction as the update (peer
|
|
539
|
+
// blocker 1: crash between write and sync must still be recoverable).
|
|
540
|
+
incrementGeneration();
|
|
541
|
+
});
|
|
502
542
|
return getById(id);
|
|
503
543
|
}
|
|
504
544
|
|
|
505
545
|
function remove(id) {
|
|
506
|
-
|
|
546
|
+
runAtomically(() => {
|
|
547
|
+
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
548
|
+
// Mirror sync must reflect the deletion; bump desired generation so a
|
|
549
|
+
// crash between the delete and syncMirror leaves a recoverable debt.
|
|
550
|
+
incrementGeneration();
|
|
551
|
+
});
|
|
507
552
|
}
|
|
508
553
|
|
|
509
554
|
/**
|
|
@@ -542,18 +587,68 @@ export function createStore(path) {
|
|
|
542
587
|
expectedUpdatedAt
|
|
543
588
|
);
|
|
544
589
|
if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
|
|
590
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
591
|
+
runAtomically(() => { incrementGeneration(); });
|
|
545
592
|
return getById(id);
|
|
546
593
|
}
|
|
547
594
|
|
|
548
595
|
function setForget(id, forgotten) {
|
|
549
|
-
|
|
550
|
-
.
|
|
596
|
+
runAtomically(() => {
|
|
597
|
+
db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
|
|
598
|
+
.run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
|
|
599
|
+
incrementGeneration();
|
|
600
|
+
});
|
|
551
601
|
return getById(id);
|
|
552
602
|
}
|
|
553
603
|
|
|
554
604
|
function setArchived(id, archived) {
|
|
555
|
-
|
|
556
|
-
.
|
|
605
|
+
runAtomically(() => {
|
|
606
|
+
db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
|
|
607
|
+
.run(archived ? 1 : 0, nowIso(), id);
|
|
608
|
+
incrementGeneration();
|
|
609
|
+
});
|
|
610
|
+
return getById(id);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Recall/inject touch. Records last_accessed_at WITHOUT bumping the mirror
|
|
614
|
+
* generation — sleep's "unrecalled N days → demote/archive" tiering must not
|
|
615
|
+
* spin the desired generation (that would mislead the mirror peer into
|
|
616
|
+
* thinking the touched memory's content changed). Only bumps a timestamp,
|
|
617
|
+
* so it is safe on hot recall paths. */
|
|
618
|
+
function touchAccess(id) {
|
|
619
|
+
const result = db.prepare(
|
|
620
|
+
"UPDATE memories SET last_accessed_at = ? WHERE id = ?"
|
|
621
|
+
).run(nowIso(), id);
|
|
622
|
+
return result.changes > 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Sleep demotion (v0.4.1): move the full body into _full_content and replace
|
|
626
|
+
* content with a one-line summary. Skips when already demoted (_full_content
|
|
627
|
+
* present) so a replayed sleep run never double-wraps. Bumps the mirror
|
|
628
|
+
* generation because content visibly changes in the mirror file.
|
|
629
|
+
*
|
|
630
|
+
* minRefTimeMs (optional): the sleep tiering's freshness cutoff. A memory
|
|
631
|
+
* whose reference time (last_accessed_at ?? updated_at ?? created_at) is
|
|
632
|
+
* newer than the cutoff is skipped — phaseDemotion snapshots ref times up
|
|
633
|
+
* front, and a recall touch landing between the snapshot and this call must
|
|
634
|
+
* not demote a freshly-accessed memory. The check and the update run in the
|
|
635
|
+
* same synchronous transaction, so the read-then-write is atomic. */
|
|
636
|
+
function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
|
|
637
|
+
runAtomically(() => {
|
|
638
|
+
const row = db.prepare(
|
|
639
|
+
"SELECT content, _full_content, last_accessed_at, updated_at, created_at FROM memories WHERE id = ?"
|
|
640
|
+
).get(id);
|
|
641
|
+
if (!row || row._full_content) return; // idempotent: never double-wrap
|
|
642
|
+
if (minRefTimeMs != null) {
|
|
643
|
+
const ref = row.last_accessed_at ?? row.updated_at ?? row.created_at;
|
|
644
|
+
const t = ref ? new Date(ref).getTime() : NaN;
|
|
645
|
+
if (!Number.isNaN(t) && t >= minRefTimeMs) return; // freshly touched: keep full
|
|
646
|
+
}
|
|
647
|
+
db.prepare(
|
|
648
|
+
"UPDATE memories SET _full_content = ?, content = ?, updated_at = ? WHERE id = ?"
|
|
649
|
+
).run(row.content ?? "", summary ?? "", nowIso(), id);
|
|
650
|
+
incrementGeneration();
|
|
651
|
+
});
|
|
557
652
|
return getById(id);
|
|
558
653
|
}
|
|
559
654
|
|
|
@@ -682,16 +777,17 @@ export function createStore(path) {
|
|
|
682
777
|
const id = run.id ?? randomUUID();
|
|
683
778
|
const now = nowIso();
|
|
684
779
|
const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
|
|
780
|
+
const runType = run.run_type ?? "auto";
|
|
685
781
|
db.prepare(
|
|
686
782
|
`INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
|
|
687
|
-
input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch)
|
|
688
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
783
|
+
input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
|
|
784
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
689
785
|
ON CONFLICT(id) DO UPDATE SET
|
|
690
786
|
created_at=excluded.created_at, status=excluded.status, error=excluded.error,
|
|
691
787
|
provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
|
|
692
788
|
input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
|
|
693
789
|
outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
|
|
694
|
-
receipt=excluded.receipt, policy_epoch=excluded.policy_epoch`
|
|
790
|
+
receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
|
|
695
791
|
).run(
|
|
696
792
|
id,
|
|
697
793
|
run.created_at ?? now,
|
|
@@ -707,7 +803,8 @@ export function createStore(path) {
|
|
|
707
803
|
run.applied ?? 0,
|
|
708
804
|
run.summary_stored ? 1 : 0,
|
|
709
805
|
run.receipt,
|
|
710
|
-
policyEpoch
|
|
806
|
+
policyEpoch,
|
|
807
|
+
runType
|
|
711
808
|
);
|
|
712
809
|
return getDreamRun(id);
|
|
713
810
|
}
|
|
@@ -1189,7 +1286,10 @@ export function createStore(path) {
|
|
|
1189
1286
|
if (key === "dirty") {
|
|
1190
1287
|
value = value ? 1 : 0;
|
|
1191
1288
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1192
|
-
value = Math.trunc(Number(value))
|
|
1289
|
+
value = Math.trunc(Number(value));
|
|
1290
|
+
if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1291
|
+
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1292
|
+
}
|
|
1193
1293
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
1194
1294
|
value = JSON.stringify(value);
|
|
1195
1295
|
}
|
|
@@ -1220,12 +1320,15 @@ export function createStore(path) {
|
|
|
1220
1320
|
* recent as this one may.
|
|
1221
1321
|
*/
|
|
1222
1322
|
function markMirrorDirty(error, now) {
|
|
1223
|
-
|
|
1323
|
+
// Bump the desired generation atomically first — the new debt must be bound
|
|
1324
|
+
// to a fresh round so a stale worker cannot fence-clean it. Even if this
|
|
1325
|
+
// write fails (peer blocker 2), generation still advanced, so recoverMirror
|
|
1326
|
+
// sees generation > applied_generation and retries rather than false-clean.
|
|
1327
|
+
incrementGeneration();
|
|
1224
1328
|
return setMirrorState({
|
|
1225
1329
|
dirty: 1,
|
|
1226
1330
|
last_error: error,
|
|
1227
|
-
last_attempt: now ?? nowIso()
|
|
1228
|
-
generation: (current.generation || 0) + 1
|
|
1331
|
+
last_attempt: now ?? nowIso()
|
|
1229
1332
|
});
|
|
1230
1333
|
}
|
|
1231
1334
|
|
|
@@ -1262,13 +1365,23 @@ export function createStore(path) {
|
|
|
1262
1365
|
|
|
1263
1366
|
/**
|
|
1264
1367
|
* Record per-type mirror status (partial success bookkeeping). `status` is a
|
|
1265
|
-
*
|
|
1266
|
-
* entry for `type` (other types untouched).
|
|
1368
|
+
* patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
|
|
1369
|
+
* replacing the entry for `type` (other types untouched). Standardizing on an
|
|
1370
|
+
* explicit status gives per-type committed/failed/pending receipts — a type
|
|
1371
|
+
* whose file was written while a sibling failed is recorded as such, not
|
|
1372
|
+
* collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
|
|
1267
1373
|
*/
|
|
1268
1374
|
function setTypeStatus(type, status) {
|
|
1375
|
+
if (!VALID_TYPE_STATUS.has(status?.status)) {
|
|
1376
|
+
throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
|
|
1377
|
+
}
|
|
1269
1378
|
const current = getMirrorState();
|
|
1270
1379
|
const statuses = current.type_status || {};
|
|
1271
|
-
statuses[type] = {
|
|
1380
|
+
statuses[type] = {
|
|
1381
|
+
status: status.status,
|
|
1382
|
+
...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
|
|
1383
|
+
...(status.last_error !== undefined ? { last_error: status.last_error } : {})
|
|
1384
|
+
};
|
|
1272
1385
|
return setMirrorState({ type_status: JSON.stringify(statuses) });
|
|
1273
1386
|
}
|
|
1274
1387
|
|
|
@@ -1278,10 +1391,40 @@ export function createStore(path) {
|
|
|
1278
1391
|
return current.type_status || {};
|
|
1279
1392
|
}
|
|
1280
1393
|
|
|
1281
|
-
/**
|
|
1394
|
+
/** Run fn atomically: when the connection is already inside a transaction
|
|
1395
|
+
* (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
|
|
1396
|
+
* Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
|
|
1397
|
+
* bump commit together: a crash between them can never leave a mutated store
|
|
1398
|
+
* with generation == applied (audit peer blocker 1, "crash window"). */
|
|
1399
|
+
function runAtomically(fn) {
|
|
1400
|
+
if (db.isTransaction) return fn();
|
|
1401
|
+
db.exec("BEGIN");
|
|
1402
|
+
try {
|
|
1403
|
+
const result = fn();
|
|
1404
|
+
db.exec("COMMIT");
|
|
1405
|
+
return result;
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
|
|
1408
|
+
throw error;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Bump the desired generation atomically (SQLite single-statement increment,
|
|
1413
|
+
* no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
|
|
1414
|
+
* increments under an 8-process probe). Returns the new mirror state.
|
|
1415
|
+
* Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
|
|
1416
|
+
* reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
|
|
1282
1417
|
function incrementGeneration() {
|
|
1283
|
-
|
|
1284
|
-
|
|
1418
|
+
return runAtomically(() => {
|
|
1419
|
+
// Ensure the singleton row exists before incrementing (UPDATE alone would
|
|
1420
|
+
// match nothing on a fresh DB).
|
|
1421
|
+
db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
|
|
1422
|
+
const row = db.prepare(
|
|
1423
|
+
"UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
|
|
1424
|
+
).get(Number.MAX_SAFE_INTEGER);
|
|
1425
|
+
if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
|
|
1426
|
+
return getMirrorState();
|
|
1427
|
+
});
|
|
1285
1428
|
}
|
|
1286
1429
|
|
|
1287
1430
|
return {
|
|
@@ -1294,6 +1437,8 @@ export function createStore(path) {
|
|
|
1294
1437
|
remove,
|
|
1295
1438
|
setForget,
|
|
1296
1439
|
setArchived,
|
|
1440
|
+
touchAccess,
|
|
1441
|
+
demoteToSummary,
|
|
1297
1442
|
list,
|
|
1298
1443
|
all,
|
|
1299
1444
|
search,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/src/api.js
CHANGED
|
@@ -289,6 +289,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
289
289
|
sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
|
|
290
290
|
return;
|
|
291
291
|
}
|
|
292
|
+
// Real read failure surfaces as dirty === null (peer blocker 5): report
|
|
293
|
+
// unknown explicitly instead of collapsing into a false "ok"/"degraded".
|
|
294
|
+
if (state.dirty === null) {
|
|
295
|
+
sendJson(res, 200, {
|
|
296
|
+
mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
292
300
|
// Sanitized: boolean dirty + coarse status only; error string is mapped to
|
|
293
301
|
// a bounded code, never echoed verbatim.
|
|
294
302
|
let code = null;
|
package/src/config.js
CHANGED
|
@@ -91,4 +91,32 @@ export const Config = z.object({
|
|
|
91
91
|
entityExtractionMaxAttrs: z.natural().min(1).max(50).default(20),
|
|
92
92
|
// Prefix/semantic search over entity names (used by recall).
|
|
93
93
|
entitySearchEnabled: z.boolean().default(true),
|
|
94
|
+
|
|
95
|
+
// --- system-level sleep (v0.4.1) -----------------------------------------
|
|
96
|
+
// Opt-in: when false (default) the plugin never runs a sleep cycle, so the
|
|
97
|
+
// access-touch bookkeeping on recall/inject paths stays off too. Sleep is
|
|
98
|
+
// three phases: conflict resolution (reuses the dream conflict machinery),
|
|
99
|
+
// archival demotion (unrecalled memories tier down to summary then archive),
|
|
100
|
+
// and pattern discovery (LLM scans recent memories and mints type=pattern
|
|
101
|
+
// entries with evidence references).
|
|
102
|
+
sleepEnabled: z.boolean().default(false),
|
|
103
|
+
// A sleep run only fires when the store has been idle for this long and the
|
|
104
|
+
// last run is older than sleepMinIntervalHours. Idle detection replaces a
|
|
105
|
+
// cron-like schedule (DSH plugins have no resident crontab).
|
|
106
|
+
sleepIdleMinutes: z.natural().min(1).max(1440).default(30),
|
|
107
|
+
sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
|
|
108
|
+
// Unrecalled (COALESCE(last_accessed_at, updated_at, created_at)) beyond
|
|
109
|
+
// sleepArchiveDays → demote: full body moves to _full_content, content
|
|
110
|
+
// becomes a one-line summary. Beyond sleepDeepArchiveDays → archive.
|
|
111
|
+
sleepArchiveDays: z.natural().min(1).max(365).default(30),
|
|
112
|
+
sleepDeepArchiveDays: z.natural().min(1).max(3650).default(90),
|
|
113
|
+
// How many most-recent memories the pattern-discovery pass scans.
|
|
114
|
+
sleepPatternScanCount: z.natural().min(10).max(500).default(100),
|
|
115
|
+
// Max patterns minted per sleep run (mirrors decisions maxCreatePerRun).
|
|
116
|
+
sleepMaxPatterns: z.natural().min(1).max(20).default(5),
|
|
117
|
+
// Optional LLM route override; empty = fall back to agentDefaultModel then
|
|
118
|
+
// the dream route. Distinct from dreamProvider/dreamModel so the sleep pass
|
|
119
|
+
// can pin a cheaper model for its bulk summarization.
|
|
120
|
+
sleepProvider: z.string().default(""),
|
|
121
|
+
sleepModel: z.string().default(""),
|
|
94
122
|
});
|
package/src/dream/decisions.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
|
|
1
|
+
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
|
|
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"]);
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
@@ -9,6 +13,7 @@ const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
|
|
|
9
13
|
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
10
14
|
const errors = [];
|
|
11
15
|
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
16
|
+
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
12
17
|
const minAgeHours = options.minAgeHours ?? 24;
|
|
13
18
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
14
19
|
return { ok: false, errors: ["decision list must be a non-empty array"] };
|
|
@@ -20,6 +25,35 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
20
25
|
errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
21
26
|
continue;
|
|
22
27
|
}
|
|
28
|
+
// create claims no existing id: it fabricates a new memory, so it runs its
|
|
29
|
+
// own field validation and skips the ids-required check + the claimed set.
|
|
30
|
+
if (d.action === "create") {
|
|
31
|
+
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
32
|
+
errors.push(`${at}: create needs non-empty title`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
36
|
+
errors.push(`${at}: create needs non-empty content`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
40
|
+
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
|
+
}
|
|
47
|
+
if (d.evidence !== undefined && !Array.isArray(d.evidence)) {
|
|
48
|
+
errors.push(`${at}: create evidence must be an array of memory ids`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (d.tags !== undefined && !Array.isArray(d.tags)) {
|
|
52
|
+
errors.push(`${at}: create tags must be an array`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
23
57
|
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
24
58
|
if (d.action === "conflict") {
|
|
25
59
|
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
@@ -95,6 +129,12 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
95
129
|
if (updateCount > maxUpdatePerRun) {
|
|
96
130
|
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
97
131
|
}
|
|
132
|
+
// Cap create churn: a pattern-discovery loop fabricating endless new memories
|
|
133
|
+
// would bloat the store, so a run can mint at most maxCreatePerRun.
|
|
134
|
+
const createCount = decisions.filter((d) => d.action === "create").length;
|
|
135
|
+
if (createCount > maxCreatePerRun) {
|
|
136
|
+
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
137
|
+
}
|
|
98
138
|
// Every snapshot id must appear in at least one decision
|
|
99
139
|
for (const id of snapshot.keys()) {
|
|
100
140
|
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
@@ -202,10 +242,48 @@ function applyOne(d, service, snapshot, config = {}) {
|
|
|
202
242
|
case "archive": return applyArchive(d, service, snapshot);
|
|
203
243
|
case "merge": return applyMerge(d, service, snapshot, config);
|
|
204
244
|
case "conflict": return applyConflict(d, service, snapshot);
|
|
245
|
+
case "create": return applyCreate(d, service, snapshot);
|
|
205
246
|
default: return applyUpdate(d, service, snapshot, config);
|
|
206
247
|
}
|
|
207
248
|
}
|
|
208
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Mint a new memory (sleep pattern discovery). saveWithDedupe dedupes by
|
|
252
|
+
* (type, title) so a replayed create merges instead of duplicating — the
|
|
253
|
+
* idempotency guard. Evidence ids are folded into tags as `ev:<id>` so a
|
|
254
|
+
* pattern's provenance stays queryable after creation.
|
|
255
|
+
*/
|
|
256
|
+
function applyCreate(d, service, snapshot) {
|
|
257
|
+
const evidence = Array.isArray(d.evidence) ? d.evidence : [];
|
|
258
|
+
const tags = [
|
|
259
|
+
...(Array.isArray(d.tags) ? d.tags : []),
|
|
260
|
+
...evidence.map((id) => `ev:${id}`)
|
|
261
|
+
];
|
|
262
|
+
const result = service.saveWithDedupe({
|
|
263
|
+
type: d.type ?? "pattern",
|
|
264
|
+
title: d.title,
|
|
265
|
+
content: d.content,
|
|
266
|
+
importance: d.importance ?? 3,
|
|
267
|
+
tags,
|
|
268
|
+
source: "dream-create"
|
|
269
|
+
});
|
|
270
|
+
if (!result?.memory) return "skipped";
|
|
271
|
+
return {
|
|
272
|
+
applied: 1,
|
|
273
|
+
committed: {
|
|
274
|
+
action: "create",
|
|
275
|
+
id: result.memory.id,
|
|
276
|
+
type: d.type ?? "pattern",
|
|
277
|
+
title: d.title,
|
|
278
|
+
content: d.content,
|
|
279
|
+
importance: d.importance ?? 3,
|
|
280
|
+
evidence,
|
|
281
|
+
count_before: 0,
|
|
282
|
+
count_after: 1
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
209
287
|
function applyArchive(d, service, snapshot) {
|
|
210
288
|
const targets = d.ids.filter((id) => {
|
|
211
289
|
const mem = service.getById(id);
|
package/src/index.js
CHANGED
|
@@ -5,6 +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
9
|
import { createApi } from "./api.js";
|
|
9
10
|
import { createSettings } from "./settings.js";
|
|
10
11
|
import { createCommandManager } from "./commands.js";
|
|
@@ -181,6 +182,25 @@ export const apply = (ctx, config) => {
|
|
|
181
182
|
service.setDreamHook(() => dream.maybeSchedule(service));
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Sleep scheduler (v0.4.1): idle-triggered deep pass (conflict resolution +
|
|
186
|
+
// archival demotion + pattern discovery). opt-in via sleepEnabled; writes
|
|
187
|
+
// through the service reset the idle clock (setSleepHook), and when the store
|
|
188
|
+
// stays quiet for sleepIdleMinutes past the sleepMinIntervalHours gate, the
|
|
189
|
+
// scheduler fires one cycle. The onRun closure reuses the same semantic
|
|
190
|
+
// pipeline as dream for the conflict phase.
|
|
191
|
+
let sleep = null;
|
|
192
|
+
if (cfg.sleepEnabled) {
|
|
193
|
+
sleep = createSleepScheduler({
|
|
194
|
+
service,
|
|
195
|
+
config: cfg,
|
|
196
|
+
logger: ctx.logger,
|
|
197
|
+
onRun: () => (sleep
|
|
198
|
+
? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex })
|
|
199
|
+
: Promise.resolve({ ok: true, skipped: true }))
|
|
200
|
+
});
|
|
201
|
+
service.setSleepHook(() => sleep.noteWrite());
|
|
202
|
+
}
|
|
203
|
+
|
|
184
204
|
// Entity gene extraction (v0.3.0): wire the extractor into the service as a
|
|
185
205
|
// hook so saveWithDedupe can fire-and-forget an extraction pass on fresh
|
|
186
206
|
// writes. The service never sees ctx.llm — index.js adapts it here into the
|
|
@@ -251,6 +271,7 @@ export const apply = (ctx, config) => {
|
|
|
251
271
|
}
|
|
252
272
|
commands?.dispose();
|
|
253
273
|
if (dream) await dream.dispose();
|
|
274
|
+
if (sleep) await sleep.dispose();
|
|
254
275
|
store.close();
|
|
255
276
|
};
|
|
256
277
|
};
|