@modusensus/dsh-mneme 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +15 -5
  2. package/lib/api-standalone.js +12 -0
  3. package/lib/api.js +52 -14
  4. package/lib/client.js +130 -8
  5. package/lib/config.js +8 -0
  6. package/lib/dream/decisions.js +55 -8
  7. package/lib/dream/sleep.js +79 -17
  8. package/lib/dream.js +61 -9
  9. package/lib/entities/extractor.js +2 -2
  10. package/lib/index.js +6 -0
  11. package/lib/lang.js +13 -2
  12. package/lib/local-embedder.js +9 -1
  13. package/lib/reranker.js +38 -8
  14. package/lib/scope.js +39 -4
  15. package/lib/service.js +121 -18
  16. package/lib/store.js +243 -40
  17. package/lib/summarize.js +2 -2
  18. package/lib/tools.js +64 -30
  19. package/lib/vector-index.js +2 -8
  20. package/lib/version-check.js +122 -0
  21. package/package.json +2 -1
  22. package/scripts/sync-test-badge.mjs +61 -0
  23. package/src/api-standalone.js +12 -0
  24. package/src/api.js +52 -14
  25. package/src/config.js +8 -0
  26. package/src/dream/decisions.js +55 -8
  27. package/src/dream/sleep.js +79 -17
  28. package/src/dream.js +61 -9
  29. package/src/entities/extractor.js +2 -2
  30. package/src/index.js +6 -0
  31. package/src/lang.js +13 -2
  32. package/src/local-embedder.js +9 -1
  33. package/src/reranker.js +38 -8
  34. package/src/scope.js +39 -4
  35. package/src/service.js +121 -18
  36. package/src/store.js +243 -40
  37. package/src/summarize.js +2 -2
  38. package/src/tools.js +64 -30
  39. package/src/vector-index.js +2 -8
  40. package/src/version-check.js +122 -0
  41. package/test/api.test.js +5 -0
  42. package/test/conflict-freeze.test.js +15 -5
  43. package/test/dream-archive-guardrail.test.js +116 -0
  44. package/test/dream-coverage-degrade.test.js +86 -0
  45. package/test/dream.test.js +15 -11
  46. package/test/inject-first-round-bm25.test.js +89 -0
  47. package/test/llm-message-source.test.js +177 -0
  48. package/test/reranker-single-logit.test.js +73 -0
  49. package/test/scope-candidates.test.js +239 -0
  50. package/test/scope-explicit.test.js +452 -0
  51. package/test/scope-storage.test.js +13 -5
  52. package/test/scope-strict.test.js +107 -41
  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
  59. package/test/version-check.test.js +152 -0
@@ -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. */
@@ -0,0 +1,122 @@
1
+ // 版本自检(issue #174 后续,2026-09-15 拍板):独立插件不把「用户在用哪个
2
+ // 版本」托付给插件市场——市场收录有 ~26h 年龄闸门 + 30min 缓存,且安装时
3
+ // 精确指定过版本的 profile 会被 pnpm 钉子挡住(update 尊重既有 semver 范围,
4
+ // 永不跨 minor/major,#174 的报障者正是这样用着 0.7.32 的行为模型来报 bug)。
5
+ //
6
+ // 本模块只做三件事:
7
+ // 1. 向 npm registry 查 @modusensus/dsh-mneme 的 latest dist-tag(带 TTL
8
+ // 缓存 + 超时 + 全失败静默——离线/被墙/registry 异常都返回 null,调用方
9
+ // 据 null 渲染「未知」,前端不打扰);
10
+ // 2. 与运行版本做纯函数比对(classify);
11
+ // 3. 对外暴露 registry URL 白名单校验(validateRegistryUrl)——发请求前
12
+ // 强制 https + 精确 host,构造上排除本机与内网地址。
13
+ import { readFileSync } from "node:fs";
14
+
15
+ // 运行版本:src/ 与 lib/ 都在插件根下一层,相对 import.meta.url 解析一致;
16
+ // 读取失败(打包/受限环境)降级 "unknown",横幅随之静默。
17
+ const PACKAGE_VERSION = (() => {
18
+ try {
19
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "unknown";
20
+ } catch {
21
+ return "unknown";
22
+ }
23
+ })();
24
+
25
+ // registry 查询地址是模块常量而非用户输入;validateRegistryUrl 仍作为发请求
26
+ // 前的强制闸(构造上即排除非 https 与非白名单 host,双保险且可单测)。
27
+ const REGISTRY_URL = "https://registry.npmjs.org/@modusensus%2Fdsh-mneme/latest";
28
+ const REGISTRY_HOST = "registry.npmjs.org";
29
+ // 市场收录本身有 ~1 天年龄闸门,1h 的 registry 缓存远低于该粒度,足够新鲜。
30
+ const CACHE_TTL_MS = 60 * 60 * 1000;
31
+ const FETCH_TIMEOUT_MS = 5000;
32
+
33
+ // 精确 host 白名单:协议必须 https,且 host 逐字等于 registry.npmjs.org。
34
+ // 这一条即构造性排除本机回环、私有网段与保留地址——不做任何
35
+ // 「看起来像公网」的宽松判断,将来换源必须显式改这里的常量。
36
+ export function validateRegistryUrl(candidate) {
37
+ let url;
38
+ try {
39
+ url = new URL(candidate);
40
+ } catch {
41
+ return false;
42
+ }
43
+ return url.protocol === "https:" && url.hostname === REGISTRY_HOST;
44
+ }
45
+
46
+ /** 解析 `x.y.z` / `x.y.z-后缀` 为可比较结构;解析失败返回 null。 */
47
+ function parseVersion(text) {
48
+ if (typeof text !== "string") return null;
49
+ const match = text.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
50
+ if (!match) return null;
51
+ return {
52
+ major: Number(match[1]),
53
+ minor: Number(match[2]),
54
+ patch: Number(match[3]),
55
+ // 有 prerelease 后缀的同号版本视为更旧(0.9.0-rc.1 < 0.9.0)。
56
+ prerelease: match[4] !== undefined
57
+ };
58
+ }
59
+
60
+ /**
61
+ * 三元组语义化比较:a<b → -1,a>b → 1,无法比较 → null(调用方归入 unknown)。
62
+ * 仅比较数字部分;数字相等时,带 prerelease 的一方更旧(0.9.0-rc.1 < 0.9.0),
63
+ * 两侧都带则视为同版——横幅只关心「是否落后于 latest」,不追求完整 semver 序。
64
+ */
65
+ export function compareVersions(a, b) {
66
+ const va = parseVersion(a);
67
+ const vb = parseVersion(b);
68
+ if (!va || !vb) return null;
69
+ for (const key of ["major", "minor", "patch"]) {
70
+ if (va[key] !== vb[key]) return va[key] < vb[key] ? -1 : 1;
71
+ }
72
+ if (va.prerelease !== vb.prerelease) return va.prerelease ? -1 : 1;
73
+ return 0;
74
+ }
75
+
76
+ /**
77
+ * 运行版本 vs registry latest 的展示归类。latest 为 null(查询失败/离线)
78
+ * 或任一侧解析失败时归 unknown——前端对 unknown 完全静默。
79
+ */
80
+ export function classify(version, latest) {
81
+ const cmp = compareVersions(version, latest);
82
+ if (cmp === null) return "unknown";
83
+ if (cmp < 0) return "outdated";
84
+ if (cmp > 0) return "ahead";
85
+ return "up-to-date";
86
+ }
87
+
88
+ // 模块级缓存:面板可能多处/多次拉取,TTL 内一律复用,不重复打 registry。
89
+ // at 用 -Infinity 作「从未拉取」哨兵:任何时钟下 now-at 都是正无穷,首轮
90
+ // 必然真实请求(测试的假时钟从 0 起也不会被误判成新鲜缓存)。
91
+ let cache = { at: -Infinity, latest: null };
92
+
93
+ /**
94
+ * 查询 registry latest。TTL 内直接命中缓存;任何失败(网络/超时/非 2xx/
95
+ * 响应形状不对/URL 校验不过)都静默返回 null——版本提示是锦上添花,绝不
96
+ * 让它成为新的故障面。失败不写缓存:TTL 起点保持旧值,下次调用立刻重试。
97
+ */
98
+ export async function fetchLatestVersion({ fetchImpl = typeof fetch === "function" ? fetch : () => Promise.reject(new Error("no fetch")), now = Date.now } = {}) {
99
+ if (now() - cache.at < CACHE_TTL_MS) return cache.latest;
100
+ if (!validateRegistryUrl(REGISTRY_URL)) return null;
101
+ let manifest;
102
+ try {
103
+ const response = await fetchImpl(REGISTRY_URL, {
104
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
105
+ headers: { accept: "application/json" }
106
+ });
107
+ if (!response.ok) return null;
108
+ manifest = await response.json();
109
+ } catch {
110
+ return null;
111
+ }
112
+ const latest = typeof manifest?.version === "string" && manifest.version ? manifest.version : null;
113
+ cache = { at: now(), latest };
114
+ return latest;
115
+ }
116
+
117
+ /** 仅供测试:清空模块级缓存与时间戳。 */
118
+ export function resetCacheForTest() {
119
+ cache = { at: -Infinity, latest: null };
120
+ }
121
+
122
+ export { PACKAGE_VERSION, REGISTRY_URL, CACHE_TTL_MS };
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.0",
4
+ "version": "0.8.2",
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
@@ -1,5 +1,4 @@
1
1
  import { URL } from "node:url";
2
- import { readFileSync } from "node:fs";
3
2
  import { timingSafeEqual, randomBytes } from "node:crypto";
4
3
  import { FEATURE_FLAG_SPEC } from "./settings.js";
5
4
  import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
@@ -8,6 +7,7 @@ import { computeHeat } from "./heat.js";
8
7
  import { describeStreamFailure, resolveRoute } from "./dream.js";
9
8
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
10
9
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
10
+ import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
11
11
 
12
12
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
13
13
  function sendJson(res, status, payload, headers = {}) {
@@ -24,16 +24,8 @@ function sendAttachment(res, status, contentType, filename, body) {
24
24
  res.end(body);
25
25
  }
26
26
 
27
- // 导出 JSON 的 version 字段:读插件根的 package.json(src/ 与 lib/ 都在根下
28
- // 一层,相对 import.meta.url 解析一致)。读取失败(打包/受限环境)降级为
29
- // "unknown",导出本身仍然可用。
30
- const PACKAGE_VERSION = (() => {
31
- try {
32
- return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "unknown";
33
- } catch {
34
- return "unknown";
35
- }
36
- })();
27
+ // 版本号统一从 version-check.js 取(同一份读 package.json 逻辑,读取失败
28
+ // 同样降级 "unknown")——/info 与 /version-check 两个路由共用,不再各写一份。
37
29
 
38
30
  // Defaults for the standalone external API — keep in step with the schema
39
31
  // defaults in config.js (externalApiPort / externalApiHost).
@@ -175,6 +167,29 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
175
167
  }
176
168
  });
177
169
 
170
+ // 版本自检(#174 后续):运行版本 vs npm registry latest。只读,宿主围栏
171
+ // 统一鉴权(与 dream-status 同款,面板 apiFetch 直接可用)。registry 查询
172
+ // 全失败时 latest=null、status=unknown——前端对 unknown 完全静默,版本提示
173
+ // 是锦上添花,绝不因它新增故障面。
174
+ register({
175
+ kind: "exact",
176
+ path: "/api/dsh-mneme/version-check",
177
+ handler(req, res) {
178
+ if (req.method !== "GET") {
179
+ sendJson(res, 404, { error: "not-found" });
180
+ return;
181
+ }
182
+ fetchLatestVersion()
183
+ .then((latest) => sendJson(res, 200, {
184
+ version: PACKAGE_VERSION,
185
+ latest,
186
+ status: classify(PACKAGE_VERSION, latest),
187
+ checkedAt: new Date().toISOString()
188
+ }))
189
+ .catch(() => sendJson(res, 200, { version: PACKAGE_VERSION, latest: null, status: "unknown" }));
190
+ }
191
+ });
192
+
178
193
  register({
179
194
  kind: "exact",
180
195
  path: "/api/dsh-mneme/list",
@@ -315,7 +330,17 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
315
330
  // 按钮无放大风险。
316
331
  maxTokens: 1024,
317
332
  ...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
318
- 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
+ ]
319
344
  })) {
320
345
  if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
321
346
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
@@ -480,6 +505,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
480
505
  }
481
506
  patch.tags = body.tags;
482
507
  }
508
+ // v0.8.1 底座(issue #170):显式 scope 修正。null=放宽到全局,
509
+ // 字符串=收窄/改标;归一化("global"/"*"/空白→NULL + explicit 章 +
510
+ // 审计行)在 service.updateMemory 内做,路由只做形状校验。
511
+ if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
512
+ sendJson(res, 400, { error: "invalid-agent-scope" });
513
+ return;
514
+ }
515
+ if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
516
+ sendJson(res, 400, { error: "invalid-workspace-scope" });
517
+ return;
518
+ }
519
+ if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
520
+ if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
483
521
  if (body.archived !== undefined && typeof body.archived !== "boolean") {
484
522
  sendJson(res, 400, { error: "invalid-archived" });
485
523
  return;
@@ -501,7 +539,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
501
539
  ...history
502
540
  ].slice(0, 20);
503
541
  }
504
- if (Object.keys(patch).length) service.update(id, patch);
542
+ if (Object.keys(patch).length) service.update(id, patch, { actor: "panel" });
505
543
  if (body.archived !== undefined) service.setArchived(id, body.archived);
506
544
  sendJson(res, 200, { memory: service.getById(id) });
507
545
  });
@@ -1270,7 +1308,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1270
1308
  });
1271
1309
 
1272
1310
  return {
1273
- routes: 23,
1311
+ routes: 30,
1274
1312
  dispose: () => {
1275
1313
  for (const dispose of disposers) dispose();
1276
1314
  }
package/src/config.js CHANGED
@@ -111,6 +111,10 @@ export const Config = z.object({
111
111
  // 记忆做 consolidation。大记忆量下全量快照会把 LLM 输入撑爆(636 记忆 →
112
112
  // 677 "missing" errors、applied=0),窗口外的旧记忆不进 snapshot。
113
113
  dreamMaxSnapshotSize: z.natural().min(1).max(1000).default(200),
114
+ // Issue #104 方向 2:单轮 archive 决策上限。一次性大扫除(12 条互不相关主题
115
+ // 被批量归档,其中 8 条疑似误伤长保留类型)是失控信号,与 update 上限同类的
116
+ // 全局闸门:skipInvalid 也不豁免,超限整单拒绝。正常清理可调高。
117
+ dreamMaxArchivePerRun: z.natural().min(1).max(200).default(8),
114
118
  // Issue #125:候选集构造方式。"window"(默认,等同现状)只取最近
115
119
  // dreamMaxSnapshotSize 条;"hybrid" 在此基础上并入**向量翻出的高相似组**——
116
120
  // 本机实测 45 对「双方活跃且 sim≥0.85」里 0 对能同时进窗口(窗口覆盖率 11.7%),
@@ -257,6 +261,10 @@ export const Config = z.object({
257
261
  rerankBatchSize: z.natural().min(1).max(64).default(8),
258
262
  rerankMaxCandidates: z.natural().min(5).max(100).default(30),
259
263
  rerankScoreThreshold: z.number().min(0).max(1).default(0.1),
264
+ // #188:量化档,与嵌入层的 useDtype 同语义(q8 = model_quantized.onnx,约为
265
+ // fp32 体积的 1/4)。此前重排层不传 dtype,即使缓存里已有量化文件也会去下载
266
+ // 1GB 级的 model.onnx。非法值由 transformers 在 init 时抛出 → 重排降级并告警。
267
+ rerankDtype: z.string().default("q8"),
260
268
 
261
269
  // --- reflection: update decision + failure tracking (v0.2.1) ------------
262
270
  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. */
@@ -4,6 +4,8 @@ import { STR, langOf } from "../lang.js";
4
4
  // 1. conflict resolution — high-similarity same-type pairs are either parked
5
5
  // for review (freeze mode) or adjudicated by the LLM (winner kept / loser
6
6
  // archived), reusing the dream conflict machinery. Strictness-graded.
7
+ // v0.8.1 (issue #170): cross-scope pairs are ALWAYS parked for human
8
+ // ownership review with a dedicated reason — never auto-adjudicated.
7
9
  // 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
8
10
  // to a one-line summary with the full body moved to _full_content; past
9
11
  // sleepCompressDays they are archived outright.
@@ -18,6 +20,7 @@ import { STR, langOf } from "../lang.js";
18
20
  import { randomUUID, createHash } from "node:crypto";
19
21
  import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
20
22
  import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
23
+ import { scopeKeyOf } from "../scope.js";
21
24
  import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, maintainIndexAfterDream } from "../dream.js";
22
25
  import { computeHeat } from "../heat.js";
23
26
 
@@ -169,29 +172,57 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
169
172
  selected.push(p);
170
173
  }
171
174
 
172
- // Freeze mode: park pairs for manual review, no LLM required.
175
+ // v0.8.1(issue #170 第 2 步):跨 scope 相似对分流。同一内容落在两个归属下
176
+ // (任一维标注键不等即算,NULL=未标注=全局也参与比较——「全局副本 + 专属副本
177
+ // 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
178
+ // 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
179
+ // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
180
+ const isCrossScopePair = (p) =>
181
+ scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
182
+ || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
183
+ const crossPairs = selected.filter(isCrossScopePair);
184
+ const samePairs = selected.filter((p) => !isCrossScopePair(p));
185
+ const parkPair = (p, reason) => {
186
+ try {
187
+ // 返回 undefined = 该对已被人工裁决且两侧内容未变(复核项 4)——不算停车。
188
+ return service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason }) != null;
189
+ } catch (error) {
190
+ logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
191
+ return false;
192
+ }
193
+ };
194
+ const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
195
+ const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
196
+
197
+ // Freeze mode: park pairs for manual review, no LLM required. Cross-scope
198
+ // pairs carry the dedicated scope-candidate reason so the queue reads them
199
+ // as ownership decisions rather than plain duplicates.
173
200
  if (config.conflictFreezeEnabled === true) {
174
201
  let frozen = 0;
175
202
  for (const p of selected) {
176
- try {
177
- service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason: STR.similarityReason[language](p.similarity.toFixed(2)) });
178
- frozen++;
179
- } catch (error) {
180
- logger?.warn?.(`dsh-mneme sleep: failed to freeze conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
181
- }
203
+ if (parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p))) frozen++;
182
204
  }
183
- return { status: frozen > 0 ? "ok" : "noop", frozen, pairs: selected.length };
205
+ return { status: frozen > 0 ? "ok" : "noop", frozen, scopeCandidates: crossPairs.length, pairs: selected.length };
184
206
  }
185
207
 
186
- // LLM adjudication.
208
+ // 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
209
+ let scopeParked = 0;
210
+ for (const p of crossPairs) {
211
+ if (parkPair(p, scopeReason(p))) scopeParked++;
212
+ }
213
+ if (samePairs.length === 0) {
214
+ return { status: scopeParked > 0 ? "ok" : "noop", frozen: 0, scopeCandidates: scopeParked, pairs: selected.length };
215
+ }
216
+
217
+ // LLM adjudication(仅同 scope 对)。
187
218
  const route = resolveSleepRoute(ctx, config, logger);
188
- if (!route) return { status: "skipped", reason: "no llm route" };
219
+ if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length };
189
220
  const snapshot = new Map();
190
- for (const p of selected) {
221
+ for (const p of samePairs) {
191
222
  snapshot.set(p.a.id, p.a);
192
223
  snapshot.set(p.b.id, p.b);
193
224
  }
194
- const listText = selected.map((p) =>
225
+ const listText = samePairs.map((p) =>
195
226
  STR.candidateConflicts[language](p)
196
227
  ).join("\n\n");
197
228
  const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
@@ -208,8 +239,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
208
239
  maxTokens: 2048,
209
240
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
210
241
  messages: [
211
- { role: "system", content: [{ type: "text", text: conflictPrompt }] },
212
- { 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" } }
213
244
  ]
214
245
  }, (reason) => { conflictStreamFailure = describeStreamFailure(reason); });
215
246
  };
@@ -359,8 +390,8 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
359
390
  maxTokens: 2048,
360
391
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
361
392
  messages: [
362
- { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }] },
363
- { 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" } }
364
395
  ]
365
396
  }, (reason) => { patternStreamFailure = describeStreamFailure(reason); });
366
397
  };
@@ -565,7 +596,33 @@ export function createSleepScheduler({
565
596
  const delay = Math.max(0, idleMs - (now() - lastWriteAt)) + 1000;
566
597
  idleTimer = setTimeoutFn(async () => {
567
598
  idleTimer = null;
568
- 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();
569
626
  }, delay);
570
627
  idleTimer.unref?.();
571
628
  }
@@ -634,5 +691,10 @@ export function createSleepScheduler({
634
691
  }
635
692
  }
636
693
 
694
+ // Issue #187(同族缺陷):构造即挂表——重启后只要没发生过写入,此前永远没有
695
+ // 闹钟,安静多久都不会触发。lastWriteAt 已在构造时设为当前时刻,shouldRun 的
696
+ // 静默条件自然生效:重启后静默满 idleMinutes 才可能起跑。
697
+ armIdleTimer();
698
+
637
699
  return { noteWrite, maybeSchedule, shouldRun, dispose };
638
700
  }