@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/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
- db.prepare(
471
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
472
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
473
- ).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
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
- db.prepare(
490
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
491
- ).run(
492
- type,
493
- patch.title ?? existing.title,
494
- patch.content ?? existing.content,
495
- JSON.stringify(patch.tags ?? existing.tags),
496
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
497
- patch.source !== undefined ? patch.source : (existing.source ?? null),
498
- embedding,
499
- now,
500
- id
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
- db.prepare("DELETE FROM memories WHERE id = ?").run(id);
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
- const result = db.prepare(
530
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
531
- WHERE id=? AND updated_at=?`
532
- ).run(
533
- type,
534
- patch.title ?? existing.title,
535
- patch.content ?? existing.content,
536
- JSON.stringify(patch.tags ?? existing.tags),
537
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
538
- patch.source !== undefined ? patch.source : (existing.source ?? null),
539
- embedding,
540
- now,
541
- id,
542
- expectedUpdatedAt
543
- );
544
- if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
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
- db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
550
- .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
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
- db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
556
- .run(archived ? 1 : 0, nowIso(), id);
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
- value = Math.trunc(Number(value)) || 0;
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
- const current = getMirrorState();
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
- * partial patch {dirty?, applied_gen?, last_error?} merged into the existing
1266
- * entry for `type` (other types untouched). Returns the updated full state.
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] = { ...(statuses[type] || {}), ...status };
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
- /** Bump the desired generation (new sync round), returning the new state. */
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
- const current = getMirrorState();
1284
- return setMirrorState({ generation: (current.generation || 0) + 1 });
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, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.3.7",
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",
@@ -104,9 +104,11 @@ console.log(`记忆目录:${memDir}\n`);
104
104
  // 1. 装载检查
105
105
  console.log("【1】插件装载");
106
106
  const checks = [];
107
- checks.push(["注册 6 个模型工具", registeredTools.length === 6]);
107
+ checks.push(["注册 7 个模型工具", registeredTools.length === 7]);
108
108
  checks.push(["注册 2 个注入上下文", injectContexts.length === 2 && injectContexts[0].name === "memory"]);
109
- checks.push(["注册 9 条 API 路由", apiRoutes.length === 9]);
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: 7,
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
- const file = filePath(type);
133
- const items = (byType[type] ?? [])
134
- .slice()
135
- .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
136
- if (items.length === 0) {
137
- // no memories of this type: drop any stale mirror file so deleted
138
- // memories do not "resurrect" via readHumanEdits
139
- rmSync(file, { force: true });
140
- continue;
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 };