@modusensus/dsh-mneme 0.3.7 → 0.3.9
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 +31 -3
- package/lib/api.js +9 -1
- package/lib/mirror.js +24 -12
- package/lib/service.js +105 -36
- package/lib/store.js +169 -50
- package/package.json +2 -2
- package/scripts/e2e-dsh.js +4 -2
- package/src/api.js +9 -1
- package/src/mirror.js +24 -12
- package/src/service.js +105 -36
- package/src/store.js +169 -50
- package/test/mirror-generation.test.js +58 -22
- package/test/peer-blockers.test.js +190 -0
package/lib/store.js
CHANGED
|
@@ -180,14 +180,19 @@ 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 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
|
|
184
|
+
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
|
|
185
185
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
186
|
);
|
|
187
187
|
`;
|
|
188
188
|
|
|
189
189
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
190
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"]);
|
|
195
|
+
|
|
191
196
|
// Pure helpers: no shared module state.
|
|
192
197
|
|
|
193
198
|
function sanitizePage(limit, offset, defaultLimit) {
|
|
@@ -386,6 +391,13 @@ function parseJsonArray(raw) {
|
|
|
386
391
|
|
|
387
392
|
export function createStore(path) {
|
|
388
393
|
const db = new DatabaseSync(path);
|
|
394
|
+
// Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
|
|
395
|
+
// init). Switching a fresh DB to WAL takes an exclusive lock; when several
|
|
396
|
+
// processes open the same path simultaneously, that lock can fail with
|
|
397
|
+
// SQLITE_BUSY before the timeout is armed. With the timeout installed first,
|
|
398
|
+
// the WAL transition (and every later write) blocks and retries instead of
|
|
399
|
+
// failing outright, so concurrent init converges to a stable 447/447.
|
|
400
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
389
401
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
390
402
|
db.exec(SCHEMA);
|
|
391
403
|
|
|
@@ -417,6 +429,24 @@ export function createStore(path) {
|
|
|
417
429
|
db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
418
430
|
}
|
|
419
431
|
|
|
432
|
+
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
433
|
+
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
434
|
+
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
435
|
+
// round — so surface it as a hard error on open instead of silently reading it
|
|
436
|
+
// as a coherent generation. Fail-closed: the operator must repair or reset the
|
|
437
|
+
// state row rather than continue with a lie.
|
|
438
|
+
for (const col of ["generation", "applied_generation"]) {
|
|
439
|
+
const bad = db.prepare(
|
|
440
|
+
`SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
|
|
441
|
+
).get();
|
|
442
|
+
if (bad) {
|
|
443
|
+
throw new RangeError(
|
|
444
|
+
`mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
|
|
445
|
+
`repair or reset the row before opening this database`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
420
450
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
421
451
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
422
452
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -467,10 +497,17 @@ export function createStore(path) {
|
|
|
467
497
|
const embedding = Array.isArray(memory.embedding) && memory.embedding.length
|
|
468
498
|
? JSON.stringify(memory.embedding)
|
|
469
499
|
: null;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
500
|
+
runAtomically(() => {
|
|
501
|
+
db.prepare(
|
|
502
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
|
|
503
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
|
|
504
|
+
).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
|
|
505
|
+
// desired generation bumped in the same transaction as the write: once
|
|
506
|
+
// this commits, generation > applied_generation, so a crash right after
|
|
507
|
+
// (before syncMirror) is caught by recoverMirror on restart (peer
|
|
508
|
+
// blocker 1). ROLLBACK on error rolls this back with the write.
|
|
509
|
+
incrementGeneration();
|
|
510
|
+
});
|
|
474
511
|
return getById(id);
|
|
475
512
|
}
|
|
476
513
|
|
|
@@ -486,24 +523,34 @@ export function createStore(path) {
|
|
|
486
523
|
const embedding = patch.embedding !== undefined
|
|
487
524
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
488
525
|
: existing.embedding ?? null;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
526
|
+
runAtomically(() => {
|
|
527
|
+
db.prepare(
|
|
528
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
|
|
529
|
+
).run(
|
|
530
|
+
type,
|
|
531
|
+
patch.title ?? existing.title,
|
|
532
|
+
patch.content ?? existing.content,
|
|
533
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
534
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
535
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
536
|
+
embedding,
|
|
537
|
+
now,
|
|
538
|
+
id
|
|
539
|
+
);
|
|
540
|
+
// Desired generation bumped in the same transaction as the update (peer
|
|
541
|
+
// blocker 1: crash between write and sync must still be recoverable).
|
|
542
|
+
incrementGeneration();
|
|
543
|
+
});
|
|
502
544
|
return getById(id);
|
|
503
545
|
}
|
|
504
546
|
|
|
505
547
|
function remove(id) {
|
|
506
|
-
|
|
548
|
+
runAtomically(() => {
|
|
549
|
+
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
550
|
+
// Mirror sync must reflect the deletion; bump desired generation so a
|
|
551
|
+
// crash between the delete and syncMirror leaves a recoverable debt.
|
|
552
|
+
incrementGeneration();
|
|
553
|
+
});
|
|
507
554
|
}
|
|
508
555
|
|
|
509
556
|
/**
|
|
@@ -526,34 +573,53 @@ export function createStore(path) {
|
|
|
526
573
|
const embedding = patch.embedding !== undefined
|
|
527
574
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
528
575
|
: existing.embedding ?? null;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
576
|
+
// The CAS UPDATE and the desired-generation bump must commit together (audit
|
|
577
|
+
// peer A): if the UPDATE autocommits first and the process dies before the
|
|
578
|
+
// increment, the store is mutated while generation == applied_generation and
|
|
579
|
+
// dirty == false — recoverMirror sees no debt and the mirror stays stale.
|
|
580
|
+
// Wrapping both in one transaction means a CAS miss rolls back cleanly too
|
|
581
|
+
// (no write, no generation bump).
|
|
582
|
+
let applied = false;
|
|
583
|
+
runAtomically(() => {
|
|
584
|
+
const result = db.prepare(
|
|
585
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
|
|
586
|
+
WHERE id=? AND updated_at=?`
|
|
587
|
+
).run(
|
|
588
|
+
type,
|
|
589
|
+
patch.title ?? existing.title,
|
|
590
|
+
patch.content ?? existing.content,
|
|
591
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
592
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
593
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
594
|
+
embedding,
|
|
595
|
+
now,
|
|
596
|
+
id,
|
|
597
|
+
expectedUpdatedAt
|
|
598
|
+
);
|
|
599
|
+
if (result.changes === 0) return; // CAS miss: a concurrent write won
|
|
600
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
601
|
+
incrementGeneration();
|
|
602
|
+
applied = true;
|
|
603
|
+
});
|
|
604
|
+
if (!applied) return undefined;
|
|
545
605
|
return getById(id);
|
|
546
606
|
}
|
|
547
607
|
|
|
548
608
|
function setForget(id, forgotten) {
|
|
549
|
-
|
|
550
|
-
.
|
|
609
|
+
runAtomically(() => {
|
|
610
|
+
db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
|
|
611
|
+
.run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
|
|
612
|
+
incrementGeneration();
|
|
613
|
+
});
|
|
551
614
|
return getById(id);
|
|
552
615
|
}
|
|
553
616
|
|
|
554
617
|
function setArchived(id, archived) {
|
|
555
|
-
|
|
556
|
-
.
|
|
618
|
+
runAtomically(() => {
|
|
619
|
+
db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
|
|
620
|
+
.run(archived ? 1 : 0, nowIso(), id);
|
|
621
|
+
incrementGeneration();
|
|
622
|
+
});
|
|
557
623
|
return getById(id);
|
|
558
624
|
}
|
|
559
625
|
|
|
@@ -1189,7 +1255,17 @@ export function createStore(path) {
|
|
|
1189
1255
|
if (key === "dirty") {
|
|
1190
1256
|
value = value ? 1 : 0;
|
|
1191
1257
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1192
|
-
|
|
1258
|
+
// Fail-closed integer enforcement (audit peer F): never truncate. A
|
|
1259
|
+
// fractional value like 1.5 previously passed the JS gate via
|
|
1260
|
+
// Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
|
|
1261
|
+
// dirty legacy row could carry a non-integer generation that reads as a
|
|
1262
|
+
// coherent applied round. Reject non-integers outright — the caller must
|
|
1263
|
+
// pass a whole number, and a stale dirty value stays visible instead of
|
|
1264
|
+
// being "repaired" into a misleading clean integer.
|
|
1265
|
+
value = Number(value);
|
|
1266
|
+
if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1267
|
+
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1268
|
+
}
|
|
1193
1269
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
1194
1270
|
value = JSON.stringify(value);
|
|
1195
1271
|
}
|
|
@@ -1220,12 +1296,15 @@ export function createStore(path) {
|
|
|
1220
1296
|
* recent as this one may.
|
|
1221
1297
|
*/
|
|
1222
1298
|
function markMirrorDirty(error, now) {
|
|
1223
|
-
|
|
1299
|
+
// Bump the desired generation atomically first — the new debt must be bound
|
|
1300
|
+
// to a fresh round so a stale worker cannot fence-clean it. Even if this
|
|
1301
|
+
// write fails (peer blocker 2), generation still advanced, so recoverMirror
|
|
1302
|
+
// sees generation > applied_generation and retries rather than false-clean.
|
|
1303
|
+
incrementGeneration();
|
|
1224
1304
|
return setMirrorState({
|
|
1225
1305
|
dirty: 1,
|
|
1226
1306
|
last_error: error,
|
|
1227
|
-
last_attempt: now ?? nowIso()
|
|
1228
|
-
generation: (current.generation || 0) + 1
|
|
1307
|
+
last_attempt: now ?? nowIso()
|
|
1229
1308
|
});
|
|
1230
1309
|
}
|
|
1231
1310
|
|
|
@@ -1262,13 +1341,23 @@ export function createStore(path) {
|
|
|
1262
1341
|
|
|
1263
1342
|
/**
|
|
1264
1343
|
* Record per-type mirror status (partial success bookkeeping). `status` is a
|
|
1265
|
-
*
|
|
1266
|
-
* entry for `type` (other types untouched).
|
|
1344
|
+
* patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
|
|
1345
|
+
* replacing the entry for `type` (other types untouched). Standardizing on an
|
|
1346
|
+
* explicit status gives per-type committed/failed/pending receipts — a type
|
|
1347
|
+
* whose file was written while a sibling failed is recorded as such, not
|
|
1348
|
+
* collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
|
|
1267
1349
|
*/
|
|
1268
1350
|
function setTypeStatus(type, status) {
|
|
1351
|
+
if (!VALID_TYPE_STATUS.has(status?.status)) {
|
|
1352
|
+
throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
|
|
1353
|
+
}
|
|
1269
1354
|
const current = getMirrorState();
|
|
1270
1355
|
const statuses = current.type_status || {};
|
|
1271
|
-
statuses[type] = {
|
|
1356
|
+
statuses[type] = {
|
|
1357
|
+
status: status.status,
|
|
1358
|
+
...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
|
|
1359
|
+
...(status.last_error !== undefined ? { last_error: status.last_error } : {})
|
|
1360
|
+
};
|
|
1272
1361
|
return setMirrorState({ type_status: JSON.stringify(statuses) });
|
|
1273
1362
|
}
|
|
1274
1363
|
|
|
@@ -1278,10 +1367,40 @@ export function createStore(path) {
|
|
|
1278
1367
|
return current.type_status || {};
|
|
1279
1368
|
}
|
|
1280
1369
|
|
|
1281
|
-
/**
|
|
1370
|
+
/** Run fn atomically: when the connection is already inside a transaction
|
|
1371
|
+
* (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
|
|
1372
|
+
* Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
|
|
1373
|
+
* bump commit together: a crash between them can never leave a mutated store
|
|
1374
|
+
* with generation == applied (audit peer blocker 1, "crash window"). */
|
|
1375
|
+
function runAtomically(fn) {
|
|
1376
|
+
if (db.isTransaction) return fn();
|
|
1377
|
+
db.exec("BEGIN");
|
|
1378
|
+
try {
|
|
1379
|
+
const result = fn();
|
|
1380
|
+
db.exec("COMMIT");
|
|
1381
|
+
return result;
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
|
|
1384
|
+
throw error;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** Bump the desired generation atomically (SQLite single-statement increment,
|
|
1389
|
+
* no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
|
|
1390
|
+
* increments under an 8-process probe). Returns the new mirror state.
|
|
1391
|
+
* Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
|
|
1392
|
+
* reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
|
|
1282
1393
|
function incrementGeneration() {
|
|
1283
|
-
|
|
1284
|
-
|
|
1394
|
+
return runAtomically(() => {
|
|
1395
|
+
// Ensure the singleton row exists before incrementing (UPDATE alone would
|
|
1396
|
+
// match nothing on a fresh DB).
|
|
1397
|
+
db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
|
|
1398
|
+
const row = db.prepare(
|
|
1399
|
+
"UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
|
|
1400
|
+
).get(Number.MAX_SAFE_INTEGER);
|
|
1401
|
+
if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
|
|
1402
|
+
return getMirrorState();
|
|
1403
|
+
});
|
|
1285
1404
|
}
|
|
1286
1405
|
|
|
1287
1406
|
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.3.
|
|
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.3.9",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
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
|
@@ -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;
|
|
@@ -346,7 +354,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
346
354
|
});
|
|
347
355
|
|
|
348
356
|
return {
|
|
349
|
-
routes:
|
|
357
|
+
routes: 9,
|
|
350
358
|
dispose: () => {
|
|
351
359
|
for (const dispose of disposers) dispose();
|
|
352
360
|
}
|
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 };
|