@modusensus/dsh-mneme 0.8.1 → 0.8.3

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.
Files changed (58) hide show
  1. package/README.md +14 -3
  2. package/lib/api-standalone.js +12 -0
  3. package/lib/api.js +12 -2
  4. package/lib/client.js +5 -1
  5. package/lib/config.js +18 -0
  6. package/lib/dream/decisions.js +55 -8
  7. package/lib/dream/sleep.js +36 -5
  8. package/lib/dream.js +18 -7
  9. package/lib/entities/extractor.js +2 -2
  10. package/lib/index.js +8 -0
  11. package/lib/inject.js +43 -1
  12. package/lib/lang.js +2 -2
  13. package/lib/local-embedder.js +21 -1
  14. package/lib/reranker.js +47 -8
  15. package/lib/runtime/model-fetch.js +361 -0
  16. package/lib/service.js +37 -5
  17. package/lib/settings.js +1 -0
  18. package/lib/store.js +85 -25
  19. package/lib/summarize.js +2 -2
  20. package/lib/tools.js +6 -19
  21. package/lib/vector-index.js +2 -8
  22. package/package.json +2 -1
  23. package/scripts/sync-test-badge.mjs +61 -0
  24. package/src/api-standalone.js +12 -0
  25. package/src/api.js +12 -2
  26. package/src/config.js +18 -0
  27. package/src/dream/decisions.js +55 -8
  28. package/src/dream/sleep.js +36 -5
  29. package/src/dream.js +18 -7
  30. package/src/entities/extractor.js +2 -2
  31. package/src/index.js +8 -0
  32. package/src/inject.js +43 -1
  33. package/src/lang.js +2 -2
  34. package/src/local-embedder.js +21 -1
  35. package/src/reranker.js +47 -8
  36. package/src/runtime/model-fetch.js +361 -0
  37. package/src/service.js +37 -5
  38. package/src/settings.js +1 -0
  39. package/src/store.js +85 -25
  40. package/src/summarize.js +2 -2
  41. package/src/tools.js +6 -19
  42. package/src/vector-index.js +2 -8
  43. package/test/api.test.js +10 -4
  44. package/test/dream-archive-guardrail.test.js +116 -0
  45. package/test/dream-coverage-degrade.test.js +86 -0
  46. package/test/dream.test.js +15 -11
  47. package/test/inject-first-round-bm25.test.js +89 -0
  48. package/test/inject-rotation.test.js +161 -0
  49. package/test/llm-message-source.test.js +177 -0
  50. package/test/local-embedder.test.js +20 -0
  51. package/test/reranker-single-logit.test.js +73 -0
  52. package/test/runtime-model-fetch.test.js +348 -0
  53. package/test/skip-invalid-restore.test.js +7 -5
  54. package/test/sleep-scheduler-rearm.test.js +89 -0
  55. package/test/sleep.test.js +2 -2
  56. package/test/standalone-api.test.js +34 -1
  57. package/test/store-vector-cache.test.js +98 -0
  58. package/test/tools-memory-get-schema.test.js +65 -0
package/lib/store.js CHANGED
@@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS memories (
22
22
  );
23
23
  CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
24
24
  CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
25
+ -- Issue #202:all()/list() 按 updated_at 排序,无索引时走临时 B 树全表排序
26
+ -- (5k 行实测 all() 231ms → 135ms)。IF NOT EXISTS 幂等,存量库启动即建。
27
+ CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at);
25
28
 
26
29
  -- autoDream audit trail: one row per consolidation run, capturing the exact
27
30
  -- input snapshot digest + the LLM decision list + per-id outcome + a compact
@@ -717,11 +720,51 @@ export function createStore(path) {
717
720
  }
718
721
  }
719
722
 
723
+ // Issue #202:embedding 解析缓存。检索路径此前每次调用都对全部带向量行
724
+ // JSON.parse(993 × 512 维 ≈ 48 ms/次,且 SQL 拖着 10 MB 的 TEXT),余弦本身
725
+ // 只占 3%。向量只经 setEmbedding 写入(单一失效点),把解析结果按 id 缓存,
726
+ // 命中即零解析;上限 FIFO 驱逐防大库内存无界(4000 × 512 维 double ≈ 16 MB)。
727
+ const embeddingCache = new Map();
728
+ const EMBEDDING_CACHE_MAX = 4000;
729
+
730
+ function cacheEmbedding(id, vector) {
731
+ if (embeddingCache.size >= EMBEDDING_CACHE_MAX && !embeddingCache.has(id)) {
732
+ const oldest = embeddingCache.keys().next().value;
733
+ embeddingCache.delete(oldest);
734
+ }
735
+ embeddingCache.set(id, vector);
736
+ }
737
+
738
+ /** Parsed embedding for one id (cache-first); undefined when none/parse
739
+ * failure. Single read path shared by searchVector / getEmbedding(s). */
740
+ function getParsedEmbedding(id) {
741
+ const cached = embeddingCache.get(id);
742
+ if (cached) return cached;
743
+ const row = db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
744
+ if (!row?.embedding) return undefined;
745
+ try {
746
+ const v = JSON.parse(row.embedding);
747
+ if (Array.isArray(v) && v.length) {
748
+ cacheEmbedding(id, v);
749
+ return v;
750
+ }
751
+ } catch { /* corrupt embedding text: treated as absent */ }
752
+ return undefined;
753
+ }
754
+
755
+ // all()/list() 的显式列清单:从实际 schema 派生(迁移后),只排除 embedding
756
+ // 列——consolidation/sleep/inject 的消费方都不读向量,而它是全表里最重的一列
757
+ // (5k 行库 ≈ 9.4 MB TEXT),每次全表调用都白搬。经 PRAGMA 派生而非硬编码,
758
+ // schema 演进时自动跟随。
759
+ const memoryColumns = db.prepare("PRAGMA table_info(memories)").all()
760
+ .map((c) => c.name)
761
+ .filter((n) => n !== "embedding");
762
+ const memoryColumnList = memoryColumns.join(", ");
763
+
720
764
  // Per-instance monotonic timestamp guard: consecutive writes within the same
721
765
  // millisecond must still produce strictly increasing timestamps (test asserts
722
766
  // updated_at != created_at). State lives in the store closure, not module scope.
723
- let lastTs = "";
724
- function nowIso() {
767
+ let lastTs = ""; function nowIso() {
725
768
  let ts = new Date().toISOString();
726
769
  if (lastTs && ts <= lastTs) {
727
770
  const d = new Date(lastTs);
@@ -925,6 +968,10 @@ export function createStore(path) {
925
968
  now,
926
969
  id
927
970
  );
971
+ // Issue #202:update 可直写 embedding(patch.embedding),缓存条目失效,
972
+ // 下次读取按库值重解析。宁滥勿缺——即便本 patch 未含 embedding,丢一条
973
+ // 缓存只多一次点查重解析。
974
+ embeddingCache.delete(id);
928
975
  // Desired generation bumped in the same transaction as the update (peer
929
976
  // blocker 1: crash between write and sync must still be recoverable).
930
977
  incrementGeneration();
@@ -995,6 +1042,8 @@ export function createStore(path) {
995
1042
  expectedUpdatedAt
996
1043
  );
997
1044
  if (result.changes === 0) return; // CAS miss: a concurrent write won
1045
+ // Issue #202:同 update——CAS 命中即可能改写 embedding,失效缓存条目。
1046
+ embeddingCache.delete(id);
998
1047
  // Only bump desired generation on a successful CAS — a miss writes nothing.
999
1048
  incrementGeneration();
1000
1049
  applied = true;
@@ -1177,7 +1226,9 @@ export function createStore(path) {
1177
1226
  }
1178
1227
 
1179
1228
  function all() {
1180
- const rows = db.prepare("SELECT * FROM memories ORDER BY updated_at DESC").all();
1229
+ // Issue #202:显式列清单排除 embedding——toRow 本就不输出向量,SELECT *
1230
+ // 纯属把全表最重的列(9.4 MB TEXT/5k 行)拖进内存再丢弃。
1231
+ const rows = db.prepare(`SELECT ${memoryColumnList} FROM memories ORDER BY updated_at DESC`).all();
1181
1232
  return rows.map(toRow);
1182
1233
  }
1183
1234
 
@@ -1185,6 +1236,17 @@ export function createStore(path) {
1185
1236
  function setEmbedding(id, vector) {
1186
1237
  const json = Array.isArray(vector) && vector.length ? JSON.stringify(vector) : null;
1187
1238
  db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(json, id);
1239
+ // Issue #202:单一写入口即单一失效点——重写缓存条目(向量在手上不必重解析),
1240
+ // 清空则移除,删除过的 id 不会被下一次 searchVector 的 id 集合选中。
1241
+ if (json) {
1242
+ try {
1243
+ const parsed = JSON.parse(json);
1244
+ if (Array.isArray(parsed) && parsed.length) cacheEmbedding(id, parsed);
1245
+ else embeddingCache.delete(id);
1246
+ } catch { embeddingCache.delete(id); }
1247
+ } else {
1248
+ embeddingCache.delete(id);
1249
+ }
1188
1250
  }
1189
1251
 
1190
1252
  /** Batch fetch stored embeddings by id (v0.5.0 search-time semantic dedup).
@@ -1193,19 +1255,10 @@ export function createStore(path) {
1193
1255
  function getEmbeddings(ids) {
1194
1256
  const out = new Map();
1195
1257
  const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
1196
- for (let i = 0; i < list.length; i += 100) {
1197
- const chunk = list.slice(i, i + 100);
1198
- const rows = db.prepare(
1199
- `SELECT id, embedding FROM memories
1200
- WHERE embedding IS NOT NULL AND embedding != ''
1201
- AND id IN (${chunk.map(() => "?").join(",")})`
1202
- ).all(...chunk);
1203
- for (const row of rows) {
1204
- try {
1205
- const vec = JSON.parse(row.embedding);
1206
- if (Array.isArray(vec) && vec.length) out.set(row.id, vec);
1207
- } catch { /* corrupt row: skip */ }
1208
- }
1258
+ for (const id of list) {
1259
+ // Issue #202:走同一解析缓存(语义去重的批量读也免重复 parse)。
1260
+ const vec = getParsedEmbedding(id);
1261
+ if (vec) out.set(id, vec);
1209
1262
  }
1210
1263
  return out;
1211
1264
  }
@@ -1280,24 +1333,30 @@ export function createStore(path) {
1280
1333
  function searchVector(vector, { limit = 20, includeArchived = false, threshold = 0 } = {}) {
1281
1334
  if (!Array.isArray(vector) || !vector.length) return [];
1282
1335
  const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
1336
+ // Issue #202:只取 id 列(~40 KB)而非 SELECT *(~11 MB)——向量从缓存取,
1337
+ // 缓存未命中时按 id 点查 embedding 再解析回填。SQL 里保留 embedding 的
1338
+ // IS NOT NULL 过滤,保证「有向量的行」集合始终以库为准(删除/清空即时生效)。
1283
1339
  const rows = db.prepare(
1284
- `SELECT * FROM memories
1340
+ `SELECT id FROM memories
1285
1341
  WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
1286
1342
  ).all();
1287
1343
  const scored = [];
1288
1344
  for (const row of rows) {
1289
- let v;
1290
- try {
1291
- v = JSON.parse(row.embedding);
1292
- } catch {
1293
- continue;
1294
- }
1345
+ const v = getParsedEmbedding(row.id);
1346
+ if (!v) continue;
1295
1347
  const score = cosine(vector, v);
1296
- if (score >= threshold) scored.push({ row, score });
1348
+ if (score >= threshold) scored.push({ row: { id: row.id }, score });
1297
1349
  }
1298
1350
  scored.sort((a, b) => b.score - a.score);
1299
1351
  const { limit: lim } = sanitizePage(limit, 0, 20);
1300
- return scored.slice(0, lim).map(({ row, score }) => ({ ...toRow(row), score }));
1352
+ // Top-N 回表:只对入选行取完整字段(主键点查 ≤ lim 行),评分别拖着正文走。
1353
+ const getFull = db.prepare("SELECT * FROM memories WHERE id = ?");
1354
+ const out = [];
1355
+ for (const { row, score } of scored.slice(0, lim)) {
1356
+ const full = getFull.get(row.id);
1357
+ if (full) out.push({ ...toRow(full), score });
1358
+ }
1359
+ return out;
1301
1360
  }
1302
1361
 
1303
1362
  // --- autoDream audit trail ----------------------------------------------
@@ -2245,6 +2304,7 @@ export function createStore(path) {
2245
2304
  search,
2246
2305
  setEmbedding,
2247
2306
  getEmbeddings,
2307
+ getParsedEmbedding,
2248
2308
  embeddedCount,
2249
2309
  needsEmbedding,
2250
2310
  searchVector,
package/lib/summarize.js CHANGED
@@ -144,7 +144,7 @@ function collectMessages(session, maxChars = 8000, language = "zh") {
144
144
  break;
145
145
  }
146
146
  }
147
- return lines.length ? [createUserMessage({ content: [{ type: "text", text: trim(lines.join("\n"), maxChars) }] })] : [];
147
+ return lines.length ? [createUserMessage({ content: [{ type: "text", text: trim(lines.join("\n"), maxChars) }], source: { kind: "plugin", plugin: "dsh-mneme" } })] : [];
148
148
  }
149
149
 
150
150
  // ── 智能调速器(429 保护)─────────────────────────────────────────────
@@ -265,7 +265,7 @@ export function createSummarizer(ctx, service, config) {
265
265
  model: route.model,
266
266
  purpose: "summarization",
267
267
  messages: [
268
- { role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }] },
268
+ { role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
269
269
  ...messages
270
270
  ],
271
271
  signal: controller.signal
package/lib/tools.js CHANGED
@@ -39,6 +39,8 @@ const MEMORY_ITEM_SCHEMA = {
39
39
  updated_at: { type: "string", required: true }
40
40
  }
41
41
  };
42
+ // 导出供回归测试断言 memory_get 与共享 schema 同源(#184 防漂移护栏)。
43
+ export { MEMORY_ITEM_SCHEMA };
42
44
 
43
45
  // v0.8.0 A2:occurred 时间窗参数 → store 过滤选项(list/count 同口径);
44
46
  // 未传参返回空对象,既有调用方不受影响。(A3 复核:此处入参仅含 occurred 边界。)
@@ -277,25 +279,10 @@ export function createTools(ctx, service, config, embedder) {
277
279
  type: "object",
278
280
  additionalProperties: false,
279
281
  properties: {
280
- memory: {
281
- type: "object",
282
- additionalProperties: false,
283
- properties: {
284
- id: { type: "string", required: true },
285
- title: { type: "string", required: true },
286
- type: { type: "string", required: true },
287
- importance: { type: "integer", required: true },
288
- tags: { type: "array", items: { type: "string" } },
289
- content: { type: "string", required: true },
290
- source: { type: "string" },
291
- agent_scope: { type: "string" },
292
- workspace_scope: { type: "string" },
293
- sensitivity: { type: "string" },
294
- occurred_at: { type: "string" },
295
- created_at: { type: "string" },
296
- updated_at: { type: "string" }
297
- }
298
- }
282
+ // 与 memory_search / memory_list 共用同一份 item schema——DTO 由
283
+ // toApiList 统一产出,内联副本曾漏掉 v0.8.1 的 scope 来源三键,
284
+ // 任何被标注过的行都过不了 in-process 校验(#184)。
285
+ memory: MEMORY_ITEM_SCHEMA
299
286
  }
300
287
  },
301
288
  render: (_args, value) => {
@@ -56,14 +56,8 @@ export function createVectorIndex({ store, logger }) {
56
56
 
57
57
  /** Read one memory's cached vector; undefined when none/parse failure. */
58
58
  getEmbedding(id) {
59
- const row = db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
60
- if (!row?.embedding) return undefined;
61
- try {
62
- const v = JSON.parse(row.embedding);
63
- return Array.isArray(v) && v.length ? v : undefined;
64
- } catch {
65
- return undefined;
66
- }
59
+ // Issue #202:走 store 的解析缓存(与 searchVector/getEmbeddings 同源)。
60
+ return store.getParsedEmbedding(id);
67
61
  },
68
62
 
69
63
  /** Cosine search over embedded rows; results carry a 0..1 score. */
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, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.8.1",
4
+ "version": "0.8.3",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -80,6 +80,7 @@
80
80
  "prepack": "npm run sync",
81
81
  "test": "node --test test/*.test.js",
82
82
  "test:coverage": "c8 node --test test/*.test.js",
83
+ "badge:sync": "node scripts/sync-test-badge.mjs",
83
84
  "e2e": "node scripts/e2e-dsh.js",
84
85
  "stress": "node scripts/stress-dsh.js"
85
86
  },
@@ -0,0 +1,61 @@
1
+ // 发版流水线的徽章对齐(release.yml 的 badge job 调用;手动可跑 npm run badge:sync)。
2
+ //
3
+ // 做三件事:
4
+ // 1. 跑一遍全量测试(npm test),取最后的 `pass N` 汇总数;
5
+ // 2. 把双 README 的 tests 徽章(img.shields.io/badge/tests-N%20passed)与
6
+ // 开发命令注释(# N 个测试 / # N tests)刷成该数字;
7
+ // 3. 有变化就写回文件(由调用方决定是否 commit/push),无变化静默退出。
8
+ //
9
+ // 测试失败(fail>0 或进程非零退出)时以非零退出且不改任何文件——徽章永远
10
+ // 不可能指向一次没跑绿的套件。历史版本表里的「N 测试全绿」是各版本当时的
11
+ // 真实记录,刻意不在替换范围内(正则只打徽章与 `#` 注释两种形状)。
12
+ import { execSync } from "node:child_process";
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
18
+ const repoRoot = join(scriptDir, "..", "..");
19
+
20
+ // 1) 全量测试
21
+ let output;
22
+ try {
23
+ output = execSync("npm test", {
24
+ cwd: join(repoRoot, "dsh-mneme"),
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ maxBuffer: 64 * 1024 * 1024
28
+ });
29
+ } catch (error) {
30
+ const tail = String(error.stdout ?? "").split("\n").slice(-25).join("\n");
31
+ console.error("npm test 未跑绿,徽章不更新:\n" + tail);
32
+ process.exit(1);
33
+ }
34
+
35
+ // 2) 取汇总 pass 数(spec reporter 的 `ℹ pass N`;取最后一处防逐用例行干扰)
36
+ const matches = [...output.matchAll(/pass (\d+)/g)];
37
+ const pass = matches.length ? Number(matches[matches.length - 1][1]) : 0;
38
+ if (!pass) {
39
+ console.error("无法从测试输出解析 pass 数,徽章不更新");
40
+ process.exit(1);
41
+ }
42
+
43
+ // 3) 刷双 README(徽章 + 两行开发命令注释)
44
+ const targets = [
45
+ join(repoRoot, "README.md"),
46
+ join(repoRoot, "dsh-mneme", "README.md")
47
+ ];
48
+ let changed = 0;
49
+ for (const file of targets) {
50
+ let text = readFileSync(file, "utf8");
51
+ const next = text
52
+ .replace(/tests-\d+%20passed-/g, `tests-${pass}%20passed-`)
53
+ .replace(/(#\s*)\d+( 个测试)/g, `$1${pass}$2`)
54
+ .replace(/(#\s*)\d+( tests)/g, `$1${pass}$2`);
55
+ if (next !== text) {
56
+ writeFileSync(file, next);
57
+ changed += 1;
58
+ console.log(`updated: ${file}`);
59
+ }
60
+ }
61
+ console.log(`pass=${pass}, files changed=${changed}`);
@@ -152,6 +152,18 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
152
152
  return;
153
153
  }
154
154
 
155
+ // --- GET /profile: user self-description -------------------------------
156
+ if (req.method === "GET" && pathname === "/profile") {
157
+ sendJson(res, 200, { profile: settings?.getProfile?.() ?? "" });
158
+ return;
159
+ }
160
+
161
+ // --- GET /rules: agent behavior rules -----------------------------------
162
+ if (req.method === "GET" && pathname === "/rules") {
163
+ sendJson(res, 200, { rules: settings?.getRules?.() ?? [] });
164
+ return;
165
+ }
166
+
155
167
  // --- GET /memories: paged + filtered list (same semantics as the
156
168
  // internal /api/dsh-mneme/list) ------------------------------------
157
169
  if (req.method === "GET" && pathname === "/memories") {
package/src/api.js CHANGED
@@ -330,7 +330,17 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
330
330
  // 按钮无放大风险。
331
331
  maxTokens: 1024,
332
332
  ...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
333
- messages: [{ role: "system", content: [{ type: "text", text: "Reply with exactly one word: ok" }] }]
333
+ // role 用 user 而非 system(#189 后续):0.1.6-alpha.1 起适配器把 system
334
+ // 抽成顶层参数,system-only 对话会被官方 API 以
335
+ // 「messages: at least one message is required」整单拒绝——探测按钮 502。
336
+ // user 消息在任何适配器下都是一条合法对话,探测从此不怕 host 演进。
337
+ messages: [
338
+ {
339
+ role: "user",
340
+ content: [{ type: "text", text: "Reply with exactly one word: ok" }],
341
+ source: { kind: "plugin", plugin: "dsh-mneme" }
342
+ }
343
+ ]
334
344
  })) {
335
345
  if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
336
346
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
@@ -1298,7 +1308,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1298
1308
  });
1299
1309
 
1300
1310
  return {
1301
- routes: 23,
1311
+ routes: 30,
1302
1312
  dispose: () => {
1303
1313
  for (const dispose of disposers) dispose();
1304
1314
  }
package/src/config.js CHANGED
@@ -37,6 +37,11 @@ export const Config = z.object({
37
37
  distillRateLimitRetries: z.natural().min(0).max(10).default(3),
38
38
  distillRateLimitBaseDelayMs: z.natural().min(100).max(60000).default(1000),
39
39
  maxInjectedItems: z.natural().min(1).max(20).default(5),
40
+ // Issue #205:注入位跨轮轮换。同一会话里,最近 N 个不同用户查询轮次注入过
41
+ // 的记忆本轮不再优先(新鲜优先、原序回填,槽位数不变);长会话中避免相邻
42
+ // 轮次反复注入同一条(实测 15.5h 会话 86 次注入 93% 重复)。0 = 关闭(默认,
43
+ // 保持既有行为)。会话边界自动重置:新会话从零开始。
44
+ injectRotationTurns: z.natural().min(0).max(20).default(0),
40
45
  importanceThreshold: z.natural().min(1).max(5).default(3),
41
46
  // 编码记忆蒸馏(codingRetrospect,opt-in,默认关)。开启时,turn/end 蒸馏
42
47
  // 额外提取三类编码专属记忆:rejected_solution(被否决方案)/ pitfall(踩坑)/
@@ -111,6 +116,10 @@ export const Config = z.object({
111
116
  // 记忆做 consolidation。大记忆量下全量快照会把 LLM 输入撑爆(636 记忆 →
112
117
  // 677 "missing" errors、applied=0),窗口外的旧记忆不进 snapshot。
113
118
  dreamMaxSnapshotSize: z.natural().min(1).max(1000).default(200),
119
+ // Issue #104 方向 2:单轮 archive 决策上限。一次性大扫除(12 条互不相关主题
120
+ // 被批量归档,其中 8 条疑似误伤长保留类型)是失控信号,与 update 上限同类的
121
+ // 全局闸门:skipInvalid 也不豁免,超限整单拒绝。正常清理可调高。
122
+ dreamMaxArchivePerRun: z.natural().min(1).max(200).default(8),
114
123
  // Issue #125:候选集构造方式。"window"(默认,等同现状)只取最近
115
124
  // dreamMaxSnapshotSize 条;"hybrid" 在此基础上并入**向量翻出的高相似组**——
116
125
  // 本机实测 45 对「双方活跃且 sim≥0.85」里 0 对能同时进窗口(窗口覆盖率 11.7%),
@@ -176,6 +185,11 @@ export const Config = z.object({
176
185
  embedModelCacheDir: z.string().default(""),
177
186
  embedModelMirror: z.string().default("https://hf-mirror.com"),
178
187
 
188
+ // issue #194:模型文件下载的断点续传与重试。开启时 transformers.js 的 env.fetch 被
189
+ // 替换为带断点续传的实现(已收字节落盘、Range/If-Range 续传、空闲看门狗),大文件
190
+ // 中断后不必整份重来;关闭 = env.fetch 保持原样(线上回滚开关)。
191
+ resilientModelDownload: z.boolean().default(true),
192
+
179
193
  // 自管运行时目录(issue #131)。空 = ~/.dsh/mneme/runtime:里面放收编或下载来的
180
194
  // transformers + onnxruntime 闭包,插件经 src/runtime/loader.js 的三层解析加载它。
181
195
  // 目的就是让这条重依赖不必留在宿主 profile 的依赖图里——profile 是所有插件共用的
@@ -257,6 +271,10 @@ export const Config = z.object({
257
271
  rerankBatchSize: z.natural().min(1).max(64).default(8),
258
272
  rerankMaxCandidates: z.natural().min(5).max(100).default(30),
259
273
  rerankScoreThreshold: z.number().min(0).max(1).default(0.1),
274
+ // #188:量化档,与嵌入层的 useDtype 同语义(q8 = model_quantized.onnx,约为
275
+ // fp32 体积的 1/4)。此前重排层不传 dtype,即使缓存里已有量化文件也会去下载
276
+ // 1GB 级的 model.onnx。非法值由 transformers 在 init 时抛出 → 重排降级并告警。
277
+ rerankDtype: z.string().default("q8"),
260
278
 
261
279
  // --- reflection: update decision + failure tracking (v0.2.1) ------------
262
280
  reflectionUpdateEnabled: z.boolean().default(true),
@@ -11,6 +11,15 @@ export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"
11
11
  // = preferred. observation (measured) > inferred (derived) > subjective (guess).
12
12
  const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
13
13
 
14
+ // Issue #104 方向 2:archive 类型护栏的长保留类型集合——这些类型的存在意义
15
+ // 就是长期保存(踩坑记录、约束、用户偏好、挖掘出的模式),LLM 以「价值判断」
16
+ // 类理由批量归档它们时误伤损失最大。conflict 裁决的败者归档不经过 archive
17
+ // action(走 applyDecisions 的 conflict 分支),不受此护栏影响。
18
+ const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_solution", "constraint", "pitfall"]);
19
+ // 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
20
+ // 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
21
+ const ARCHIVE_RATIONALE_RE = /重复|过时|陈旧|过期|已被取代|被取代|冗余|不再使用|旧版|废弃|stale|outdated|obsolete|dup(?:licate|licat)?|superseded|redundant|deprecated|expired|replaced/i;
22
+
14
23
  /**
15
24
  * Validate a dream decision list against a snapshot of eligible memories.
16
25
  * @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
@@ -128,6 +137,21 @@ export function validateDecisions(decisions, snapshot, options = {}) {
128
137
  // update-specific field validation runs BEFORE claiming ids, so a failing
129
138
  // update never pollutes the claimed set (which drives the "every id must
130
139
  // appear in a decision" check below).
140
+ // Issue #104 方向 2:archive 类型护栏。长保留类型的 archive 决策必须携带
141
+ // 「重复 / 过时」类理由(提示词对 archive 的定义本就如此),「价值判断」类
142
+ // 理由(保持整洁、参考价值低……)不放行——单条跳过记 skipped(run 记
143
+ // degraded,明细进 dream_runs.skipped),绝不整单拒绝。未知 id 在下方通用
144
+ // 校验里另行报错,这里 snapshot.get 为 undefined 时跳过护栏判断。
145
+ if (local.length === 0 && d?.action === "archive") {
146
+ const guardedTypes = new Set();
147
+ for (const id of d.ids) {
148
+ const mem = snapshot.get(id);
149
+ if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
150
+ }
151
+ if (guardedTypes.size > 0 && !ARCHIVE_RATIONALE_RE.test(String(d.reason ?? ""))) {
152
+ local.push(`${at}: archive of long-retention type(s) ${[...guardedTypes].join(", ")} needs a duplicate/outdated rationale (重复/过时), got: ${JSON.stringify(String(d.reason ?? "")).slice(0, 60)}`);
153
+ }
154
+ }
131
155
  if (local.length === 0 && d?.action === "update") {
132
156
  // 只能更新单条
133
157
  if (!Array.isArray(d.ids) || d.ids.length !== 1) {
@@ -216,26 +240,49 @@ export function validateDecisions(decisions, snapshot, options = {}) {
216
240
  if (createCount > maxCreatePerRun) {
217
241
  errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
218
242
  }
243
+ // Issue #104 方向 2:archive 批量上限——一次性大扫除(12 条互不相关主题被
244
+ // 批量归档)是失控信号,与 update 上限同一类全局闸门:skipInvalid 也不豁免,
245
+ // 超限整单拒绝;正常的大规模清理由用户调高 dreamMaxArchivePerRun。
246
+ const archiveCount = survivors.filter((d) => d.action === "archive").length;
247
+ const maxArchivePerRun = options.maxArchivePerRun ?? 8;
248
+ if (archiveCount > maxArchivePerRun) {
249
+ errors.push(`too many archive decisions: ${archiveCount} > ${maxArchivePerRun}`);
250
+ }
251
+ // Issue #89 防洗白核心(#104 方向 1 重审后保留):全部决策都被跳过、合法
252
+ // 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
253
+ // 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
254
+ // 「有合法子集但覆盖率不足」的良性挑重点(见下方 coverageShortfall)。
255
+ if (options.skipInvalid === true && skipped.length > 0 && survivors.length === 0 && errors.length === 0) {
256
+ errors.push(`all ${skipped.length} decision(s) invalid — nothing valid survived validation`);
257
+ return { ok: false, errors, skipped, resolvedShortIds };
258
+ }
219
259
  // v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim 的
220
260
  // snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
221
261
  // 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
222
262
  // 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
223
263
  // (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
224
264
  //
225
- // v0.4.4 fix(残缺输出防洗白):先收集所有非覆盖类 errors,有错直接 ok:false
226
- // 且绝不 push 任何补齐 keep——残缺决策必须被真实拒绝,不能被隐式 keep 洗白成
227
- // ok 后再 apply。只有无错时才检查显式覆盖率:LLM 输出被截断只 claim 少量
228
- // snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
229
- // 而不是用 keep 把绝大部分 snapshot 全部"通过"。
265
+ // v0.4.4 fix(残缺输出防洗白)→ Issue #104 方向 1 重审:显式覆盖率护栏的
266
+ // 初衷是拦「输出截断 → 只 claim 少量 → 其余被隐式 keep 洗白」,但它无法区分
267
+ // 恶性截断与良性挑重点——而提示词硬性规则本身要求「无问题的条目无需输出」,
268
+ // 挑重点正是要求的行为。官方路由 + 强模型的实测(heptaspirit:81 轮 failed
269
+ // 全是 coverage,LLM 调用本身成功、applied=0)证明这是常规路径而非边角。
270
+ // 处置:覆盖率不足不再 ok:false 整单拒绝,改为返回 coverageShortfall 理由、
271
+ // 照常补齐隐式 keep——调用方把 run 记 degraded(合法子集已应用),护栏以
272
+ // 状态暴露而非一票否决,与 skipInvalid 同一宽容哲学。明确异常信号(create
273
+ // 上限等硬错误)保持整单拒绝。要恢复旧行为可设 dreamImplicitKeep:false
274
+ // (严格模式,缺失即 error)或 dreamMinExplicitCoverage: 0(关护栏)。
275
+ // 非覆盖类硬错误(create 上限溢出、严格模式缺失 id)仍然先整单拒绝——
276
+ // #104 方向 1 只豁免 coverage 这一个信号,防洗白语义对它们原样保留。
230
277
  if (errors.length > 0) {
231
278
  return { ok: false, errors, skipped, resolvedShortIds };
232
279
  }
233
280
  const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
281
+ let coverageShortfall = null;
234
282
  if (options.dreamImplicitKeep !== false) {
235
283
  const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
236
284
  if (coverage < minCoverage) {
237
- errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
238
- return { ok: false, errors, skipped, resolvedShortIds };
285
+ coverageShortfall = `explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`;
239
286
  }
240
287
  for (const id of snapshot.keys()) {
241
288
  if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
@@ -252,7 +299,7 @@ export function validateDecisions(decisions, snapshot, options = {}) {
252
299
  // 当"被跳过的非法决策数 == 隐式补齐的 keep 数"时长度回到相等但内容已变,
253
300
  // 被跳过的决策会残留进 apply/audit。一律无条件 splice 最安全。
254
301
  decisions.splice(0, decisions.length, ...survivors);
255
- return { ok: true, errors, skipped, resolvedShortIds };
302
+ return { ok: true, errors, skipped, resolvedShortIds, coverageShortfall };
256
303
  }
257
304
 
258
305
  /** Marker thrown when a decision target changed since the run snapshot. */
@@ -239,8 +239,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
239
239
  maxTokens: 2048,
240
240
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
241
241
  messages: [
242
- { role: "system", content: [{ type: "text", text: conflictPrompt }] },
243
- { role: "user", content: [{ type: "text", text: listText }] }
242
+ { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
243
+ { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
244
244
  ]
245
245
  }, (reason) => { conflictStreamFailure = describeStreamFailure(reason); });
246
246
  };
@@ -390,8 +390,8 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
390
390
  maxTokens: 2048,
391
391
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
392
392
  messages: [
393
- { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }] },
394
- { role: "user", content: [{ type: "text", text: listText }] }
393
+ { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin", plugin: "dsh-mneme" } },
394
+ { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
395
395
  ]
396
396
  }, (reason) => { patternStreamFailure = describeStreamFailure(reason); });
397
397
  };
@@ -596,7 +596,33 @@ export function createSleepScheduler({
596
596
  const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
597
597
  idleTimer = setTimeoutFn(async () => {
598
598
  idleTimer = null;
599
- await maybeSchedule();
599
+ // Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期"
600
+ // 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。
601
+ const ran = await maybeSchedule();
602
+ if (!ran) armNextWindow();
603
+ }, delay);
604
+ idleTimer.unref?.();
605
+ }
606
+
607
+ // Issue #187:撞 CD / run 失败后的补挂表——按「剩余 CD / 剩余静默」里更晚的
608
+ // 到点时刻重排一次,回调里重复同样的判断所以不会空转。跑成了一轮就不重排:
609
+ // 安静的库没有要处理的新东西,触发权交还给下一次写入的 noteWrite。
610
+ function armNextWindow() {
611
+ if (disposed || idleTimer) return;
612
+ if (config.sleepModeEnabled !== true) return;
613
+ // lastRunAt === 0 表示从未跑过(此路径不该出现,防御性返回)。
614
+ if (lastRunAt <= 0) return;
615
+ const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
616
+ const cdMs = (config.sleepMinIntervalHours ?? 8) * 3600000;
617
+ const delay = Math.max(
618
+ lastRunAt + cdMs - now(),
619
+ lastWriteAt + idleMs - now(),
620
+ 0
621
+ ) + 1000;
622
+ idleTimer = setTimeoutFn(async () => {
623
+ idleTimer = null;
624
+ const ran = await maybeSchedule();
625
+ if (!ran) armNextWindow();
600
626
  }, delay);
601
627
  idleTimer.unref?.();
602
628
  }
@@ -665,5 +691,10 @@ export function createSleepScheduler({
665
691
  }
666
692
  }
667
693
 
694
+ // Issue #187(同族缺陷):构造即挂表——重启后只要没发生过写入,此前永远没有
695
+ // 闹钟,安静多久都不会触发。lastWriteAt 已在构造时设为当前时刻,shouldRun 的
696
+ // 静默条件自然生效:重启后静默满 idleMinutes 才可能起跑。
697
+ armIdleTimer();
698
+
668
699
  return { noteWrite, maybeSchedule, shouldRun, dispose };
669
700
  }