@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.
- package/README.md +15 -5
- package/lib/api-standalone.js +12 -0
- package/lib/api.js +52 -14
- package/lib/client.js +130 -8
- package/lib/config.js +8 -0
- package/lib/dream/decisions.js +55 -8
- package/lib/dream/sleep.js +79 -17
- package/lib/dream.js +61 -9
- package/lib/entities/extractor.js +2 -2
- package/lib/index.js +6 -0
- package/lib/lang.js +13 -2
- package/lib/local-embedder.js +9 -1
- package/lib/reranker.js +38 -8
- package/lib/scope.js +39 -4
- package/lib/service.js +121 -18
- package/lib/store.js +243 -40
- package/lib/summarize.js +2 -2
- package/lib/tools.js +64 -30
- package/lib/vector-index.js +2 -8
- package/lib/version-check.js +122 -0
- package/package.json +2 -1
- package/scripts/sync-test-badge.mjs +61 -0
- package/src/api-standalone.js +12 -0
- package/src/api.js +52 -14
- package/src/config.js +8 -0
- package/src/dream/decisions.js +55 -8
- package/src/dream/sleep.js +79 -17
- package/src/dream.js +61 -9
- package/src/entities/extractor.js +2 -2
- package/src/index.js +6 -0
- package/src/lang.js +13 -2
- package/src/local-embedder.js +9 -1
- package/src/reranker.js +38 -8
- package/src/scope.js +39 -4
- package/src/service.js +121 -18
- package/src/store.js +243 -40
- package/src/summarize.js +2 -2
- package/src/tools.js +64 -30
- package/src/vector-index.js +2 -8
- package/src/version-check.js +122 -0
- package/test/api.test.js +5 -0
- package/test/conflict-freeze.test.js +15 -5
- package/test/dream-archive-guardrail.test.js +116 -0
- package/test/dream-coverage-degrade.test.js +86 -0
- package/test/dream.test.js +15 -11
- package/test/inject-first-round-bm25.test.js +89 -0
- package/test/llm-message-source.test.js +177 -0
- package/test/reranker-single-logit.test.js +73 -0
- package/test/scope-candidates.test.js +239 -0
- package/test/scope-explicit.test.js +452 -0
- package/test/scope-storage.test.js +13 -5
- package/test/scope-strict.test.js +107 -41
- package/test/skip-invalid-restore.test.js +7 -5
- package/test/sleep-scheduler-rearm.test.js +89 -0
- package/test/sleep.test.js +2 -2
- package/test/standalone-api.test.js +34 -1
- package/test/store-vector-cache.test.js +98 -0
- package/test/tools-memory-get-schema.test.js +65 -0
- package/test/version-check.test.js +152 -0
package/lib/vector-index.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
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.
|
|
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}`);
|
package/src/api-standalone.js
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
|
-
//
|
|
28
|
-
//
|
|
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
|
-
|
|
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:
|
|
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),
|
package/src/dream/decisions.js
CHANGED
|
@@ -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
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
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
|
-
|
|
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. */
|
package/src/dream/sleep.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|