@modusensus/dsh-mneme 0.8.0 → 0.8.1
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 +4 -2
- package/lib/api.js +40 -12
- package/lib/client.js +130 -8
- package/lib/dream/sleep.js +43 -12
- package/lib/dream.js +43 -2
- package/lib/lang.js +11 -0
- package/lib/scope.js +39 -4
- package/lib/service.js +105 -18
- package/lib/store.js +158 -15
- package/lib/tools.js +58 -11
- package/lib/version-check.js +122 -0
- package/package.json +1 -1
- package/src/api.js +40 -12
- package/src/dream/sleep.js +43 -12
- package/src/dream.js +43 -2
- package/src/lang.js +11 -0
- package/src/scope.js +39 -4
- package/src/service.js +105 -18
- package/src/store.js +158 -15
- package/src/tools.js +58 -11
- package/src/version-check.js +122 -0
- package/test/conflict-freeze.test.js +15 -5
- 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/version-check.test.js +152 -0
|
@@ -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.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
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",
|
|
@@ -480,6 +495,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
480
495
|
}
|
|
481
496
|
patch.tags = body.tags;
|
|
482
497
|
}
|
|
498
|
+
// v0.8.1 底座(issue #170):显式 scope 修正。null=放宽到全局,
|
|
499
|
+
// 字符串=收窄/改标;归一化("global"/"*"/空白→NULL + explicit 章 +
|
|
500
|
+
// 审计行)在 service.updateMemory 内做,路由只做形状校验。
|
|
501
|
+
if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
|
|
502
|
+
sendJson(res, 400, { error: "invalid-agent-scope" });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
|
|
506
|
+
sendJson(res, 400, { error: "invalid-workspace-scope" });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
|
|
510
|
+
if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
|
|
483
511
|
if (body.archived !== undefined && typeof body.archived !== "boolean") {
|
|
484
512
|
sendJson(res, 400, { error: "invalid-archived" });
|
|
485
513
|
return;
|
|
@@ -501,7 +529,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
501
529
|
...history
|
|
502
530
|
].slice(0, 20);
|
|
503
531
|
}
|
|
504
|
-
if (Object.keys(patch).length) service.update(id, patch);
|
|
532
|
+
if (Object.keys(patch).length) service.update(id, patch, { actor: "panel" });
|
|
505
533
|
if (body.archived !== undefined) service.setArchived(id, body.archived);
|
|
506
534
|
sendJson(res, 200, { memory: service.getById(id) });
|
|
507
535
|
});
|
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);
|
package/src/dream.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
|
|
3
|
+
import { scopeKeyOf } from "./scope.js";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { STR, langOf } from "./lang.js";
|
|
5
6
|
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
|
|
@@ -989,7 +990,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
989
990
|
// bounds the review queue; overflow is skipped with a warning.
|
|
990
991
|
let frozenCount = 0;
|
|
991
992
|
const frozenIds = [];
|
|
992
|
-
|
|
993
|
+
// v0.8.1(issue #170 复核项 3):跨 scope 的 conflict 决策两种模式都一律
|
|
994
|
+
// 停车人工裁决、绝不自动归档——否则普通 dream 的 LLM 能归档别的 scope 的
|
|
995
|
+
// 唯一副本,sleep 路径(scope-candidate-queue)的保护在这里缺位。同 scope
|
|
996
|
+
// 的 conflict 决策照旧:freeze 停车 / 非 freeze 自动裁决。
|
|
997
|
+
const isCrossScopeDecision = (d) => {
|
|
998
|
+
const a = snapshot.get(d.winner);
|
|
999
|
+
const b = snapshot.get(d.loser);
|
|
1000
|
+
return !!a && !!b && (
|
|
1001
|
+
scopeKeyOf(a.agent_scope) !== scopeKeyOf(b.agent_scope)
|
|
1002
|
+
|| scopeKeyOf(a.workspace_scope) !== scopeKeyOf(b.workspace_scope)
|
|
1003
|
+
);
|
|
1004
|
+
};
|
|
1005
|
+
const applyList = freezeEnabled
|
|
1006
|
+
? decisions.filter((d) => d.action !== "conflict")
|
|
1007
|
+
: decisions.filter((d) => d.action !== "conflict" || !isCrossScopeDecision(d));
|
|
993
1008
|
if (freezeEnabled) {
|
|
994
1009
|
const conflictsToFreeze = decisions.filter((d) => d.action === "conflict");
|
|
995
1010
|
if (conflictsToFreeze.length > 0) {
|
|
@@ -1003,7 +1018,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1003
1018
|
}
|
|
1004
1019
|
for (const d of toFreeze) {
|
|
1005
1020
|
try {
|
|
1006
|
-
|
|
1021
|
+
// 返回 undefined = 该对已被人工裁决且两侧内容未变(issue #170 复核
|
|
1022
|
+
// 项 4:内容变了才重新入队)——不算停车、不进 frozenIds。
|
|
1023
|
+
const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
|
|
1024
|
+
if (parked == null) continue;
|
|
1007
1025
|
frozenCount++;
|
|
1008
1026
|
frozenIds.push(d.winner, d.loser);
|
|
1009
1027
|
} catch (error) {
|
|
@@ -1014,6 +1032,29 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1014
1032
|
logger?.warn?.(`dsh-mneme dream: conflict freeze lookup failed: ${String(error)}`);
|
|
1015
1033
|
}
|
|
1016
1034
|
}
|
|
1035
|
+
} else {
|
|
1036
|
+
// 非 freeze:跨 scope conflict 停车(绝不进 applyDecisions)。带专属
|
|
1037
|
+
// reason + 模型给出的裁决理由,队列里读起来是「归属裁决」而非重复项。
|
|
1038
|
+
const crossScope = decisions.filter((d) => d.action === "conflict" && isCrossScopeDecision(d));
|
|
1039
|
+
for (const d of crossScope) {
|
|
1040
|
+
try {
|
|
1041
|
+
const parked = service.saveConflictPending({
|
|
1042
|
+
run_id: runId,
|
|
1043
|
+
memory_a: d.winner,
|
|
1044
|
+
memory_b: d.loser,
|
|
1045
|
+
reason: STR.scopeConflictParkReason[language](d.reason)
|
|
1046
|
+
});
|
|
1047
|
+
if (parked != null) {
|
|
1048
|
+
frozenCount++;
|
|
1049
|
+
frozenIds.push(d.winner, d.loser);
|
|
1050
|
+
}
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
logger?.warn?.(`dsh-mneme dream: failed to park cross-scope conflict ${d.winner}/${d.loser}: ${String(error)}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (crossScope.length > 0) {
|
|
1056
|
+
logger?.info?.(`[dsh-mneme] dream: parked ${crossScope.length} cross-scope conflict(s) for human ownership review`);
|
|
1057
|
+
}
|
|
1017
1058
|
}
|
|
1018
1059
|
|
|
1019
1060
|
// CAS-guarded, per-decision-transactional apply against the run snapshot:
|
package/src/lang.js
CHANGED
|
@@ -103,6 +103,17 @@ export const STR = {
|
|
|
103
103
|
|
|
104
104
|
// --- dream/sleep.js:冲突候选列表 --------------------------------------------
|
|
105
105
|
similarityReason: { zh: (s) => `相似度 ${s}`, en: (s) => `similarity ${s}` },
|
|
106
|
+
// v0.8.1(issue #170 第 2 步):跨 scope 相似对停车到冲突队列的专属 reason。
|
|
107
|
+
scopeCandidateReason: {
|
|
108
|
+
zh: (s) => `跨作用域相似 ${s}(疑似同一内容落在两个归属下,请裁决归属/去重)`,
|
|
109
|
+
en: (s) => `cross-scope similar ${s} (same content under two scopes — review ownership)`
|
|
110
|
+
},
|
|
111
|
+
// v0.8.1(issue #170 复核项 3):普通 dream 的 LLM 输出跨 scope conflict 时
|
|
112
|
+
// 停车用(相似度未知,带模型给的裁决理由)。
|
|
113
|
+
scopeConflictParkReason: {
|
|
114
|
+
zh: (r) => `跨作用域冲突(归属不同,不自动裁决)${r ? `:${r}` : ""}`,
|
|
115
|
+
en: (r) => `cross-scope conflict (different ownership — not auto-adjudicated)${r ? `: ${r}` : ""}`
|
|
116
|
+
},
|
|
106
117
|
candidateConflicts: {
|
|
107
118
|
zh: (p) => `候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`,
|
|
108
119
|
en: (p) => `Candidate conflicts:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(similarity ${p.similarity.toFixed(2)})`
|
package/src/scope.js
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
// 硬约束:解析失败绝不阻塞写入——任何一步取不到就落 NULL(读侧 NULL = 未标注
|
|
9
9
|
// = 全局可见),任何异常都被吞掉(registry 故障 warn 一次后降级)。
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// v0.8.1(issue #170 复核项 2):scopeEnabled 的闸门从解析器挪到「写标注」处
|
|
12
|
+
// (memory_save 的 auto 分支)。解析器恒返回会话身份(真取不到才是 null)——
|
|
13
|
+
// 读取侧(strictVisibility / memory_get / inject)与显式声明都依赖它:flag 关
|
|
14
|
+
// 只关「自动标注」,不能连读取身份一起关,否则 strictScope 硬墙被静默拆掉
|
|
15
|
+
// (fail-open)。
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* @param {object} deps
|
|
@@ -31,8 +34,6 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
return function resolveWriteScope(exec) {
|
|
34
|
-
if (config?.scopeEnabled !== true) return null;
|
|
35
|
-
|
|
36
37
|
let session = null;
|
|
37
38
|
try {
|
|
38
39
|
session = exec?.agent?.session ?? null;
|
|
@@ -81,3 +82,37 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
|
81
82
|
return { agent_scope: agentScope, workspace_scope: workspaceScope };
|
|
82
83
|
};
|
|
83
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* v0.8.1 底座(issue #170):显式 scope 参数(memory_save / memory_update /
|
|
88
|
+
* 面板编辑)的归一化。约定:
|
|
89
|
+
* - 省略(undefined,由调用方判断)= 该维不声明,回落载体自动标注;
|
|
90
|
+
* - "global"(大小写不敏感)/ "*" / 空白 = 显式声明全局 → 存储 NULL
|
|
91
|
+
* (行上的 agent_scope_source='explicit' 承担「显式全局」与「从未标注」
|
|
92
|
+
* 的区分,NULL 本身继续表示全局可见);
|
|
93
|
+
* - 其余非空 trim 字符串 = 收窄到该标签。
|
|
94
|
+
* 脏输入(非字符串)落 NULL(等同声明全局)——与 resolveWriteScope 的「解析
|
|
95
|
+
* 绝不阻塞写入」同一哲学:显式声明是用户意图,形状不对时宁可放宽不报错。
|
|
96
|
+
*
|
|
97
|
+
* @param {unknown} raw 工具/HTTP 参数原值
|
|
98
|
+
* @returns {string|null} 归一化后的标签值(null=显式全局)
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeExplicitScope(raw) {
|
|
101
|
+
if (typeof raw !== "string") return null;
|
|
102
|
+
const s = raw.trim();
|
|
103
|
+
if (!s || s === "*" || s.toLowerCase() === "global") return null;
|
|
104
|
+
return s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* scope 标注的比较键(去重键/检索加权/跨 scope 配对共用口径):非空 trim 字符串
|
|
109
|
+
* 原样返回,其余(NULL/undefined/空串/非字符串)统一为 null(「未标注」)。
|
|
110
|
+
* v0.8.1(issue #170 第 2 步)从 service.js 上提为共享定义——sleep 的跨 scope
|
|
111
|
+
* 候选配对必须与去重键用同一把钥匙,NULL 与已标注才不会错配。
|
|
112
|
+
*
|
|
113
|
+
* @param {unknown} v scope 标注原值
|
|
114
|
+
* @returns {string|null} 比较键(null=未标注)
|
|
115
|
+
*/
|
|
116
|
+
export function scopeKeyOf(v) {
|
|
117
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
118
|
+
}
|