@modusensus/dsh-mneme 0.7.32 → 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 +44 -20
- package/lib/api.js +100 -12
- package/lib/client.js +276 -4
- package/lib/commands.js +30 -3
- package/lib/config.js +21 -0
- package/lib/dream/sleep.js +43 -12
- package/lib/dream.js +43 -2
- package/lib/inject.js +39 -5
- package/lib/lang.js +23 -0
- package/lib/scope.js +118 -0
- package/lib/service.js +374 -62
- package/lib/settings.js +7 -0
- package/lib/store.js +245 -7
- package/lib/tools.js +138 -17
- package/lib/version-check.js +122 -0
- package/package.json +1 -1
- package/src/api.js +100 -12
- package/src/commands.js +30 -3
- package/src/config.js +21 -0
- package/src/dream/sleep.js +43 -12
- package/src/dream.js +43 -2
- package/src/inject.js +39 -5
- package/src/lang.js +23 -0
- package/src/scope.js +118 -0
- package/src/service.js +374 -62
- package/src/settings.js +7 -0
- package/src/store.js +245 -7
- package/src/tools.js +138 -17
- package/src/version-check.js +122 -0
- package/test/api.test.js +90 -4
- package/test/client.test.js +58 -0
- package/test/commands.test.js +40 -3
- package/test/conflict-freeze.test.js +15 -5
- package/test/inject.test.js +93 -0
- package/test/scope-candidates.test.js +239 -0
- package/test/scope-explicit.test.js +452 -0
- package/test/scope-retrieval.test.js +197 -0
- package/test/scope-storage.test.js +255 -0
- package/test/scope-strict.test.js +231 -0
- package/test/service.test.js +76 -0
- package/test/version-check.test.js +152 -0
package/lib/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/lib/inject.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createScopeResolver } from "./scope.js";
|
|
1
2
|
import { createHotMemory } from "./hot-memory.js";
|
|
2
3
|
import { STR, langOf } from "./lang.js";
|
|
3
4
|
|
|
@@ -51,8 +52,16 @@ function extractRounds(ctx, maxRounds) {
|
|
|
51
52
|
// 这里用 ?? 兜底而不是直接改成 data.message.content:user 消息与旧的扁平形状都照旧可用。
|
|
52
53
|
const parts = event?.data?.content ?? event?.data?.message?.content;
|
|
53
54
|
if (!Array.isArray(parts)) return "";
|
|
55
|
+
// 只取正文 part(type === "text" 或字符串),跳过 reasoning(issue #162):
|
|
56
|
+
// reasoning 里出现 Go template / Vue / Handlebars / 正则 / 日志原文的概率远高于
|
|
57
|
+
// 正文,把它带进 hot memory 会让 `{{...}}` 模板片段注入 prompt。旧的扁平形状
|
|
58
|
+
// (字符串 part)照旧取用,无 type 字段的兜底视为正文。
|
|
54
59
|
return parts
|
|
55
|
-
.map((p) =>
|
|
60
|
+
.map((p) => {
|
|
61
|
+
if (typeof p === "string") return p;
|
|
62
|
+
if (p?.type && p.type !== "text") return "";
|
|
63
|
+
return p?.text ?? "";
|
|
64
|
+
})
|
|
56
65
|
.filter(Boolean)
|
|
57
66
|
.join("\n")
|
|
58
67
|
.trim();
|
|
@@ -85,6 +94,10 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
85
94
|
const language = langOf(config);
|
|
86
95
|
const maxItems = config.maxInjectedItems ?? 5;
|
|
87
96
|
const threshold = config.importanceThreshold ?? 3;
|
|
97
|
+
// v0.8.0 A3(issue #17):注入路径的会话 scope 解析——strictScope 硬过滤
|
|
98
|
+
// 需要。渲染 ctx 与工具 exec 同形(agent.session),解析器直接复用。
|
|
99
|
+
// logger 透传:registry 反查失败时 warnOnce 才有出口。
|
|
100
|
+
const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
|
|
88
101
|
|
|
89
102
|
// Bug6: bound the injected memory block. Each entry's content is truncated to
|
|
90
103
|
// MAX_CONTENT chars (trailing `…`); the whole block gets a MAX_BLOCK budget
|
|
@@ -103,6 +116,24 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
103
116
|
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
|
|
104
117
|
}
|
|
105
118
|
|
|
119
|
+
// Prompt-variable brace escaping (issue #162, restored from v0.7.4 #40):
|
|
120
|
+
// DSH's interpolate() scans every injected section for `{{...}}` and throws
|
|
121
|
+
// unless the variable name matches /^[a-z][a-z0-9_]*$/ — memory/profile
|
|
122
|
+
// content carrying legal template syntax (docker `--format "{{.Server.Version}}"`,
|
|
123
|
+
// Obsidian-style `{{hl|}}`, `{{挖空}}`, `{{关键词}}`) would hit an illegal
|
|
124
|
+
// variable name and throw, crashing the whole turn. At the injection boundary
|
|
125
|
+
// we escape every run of 2+ consecutive braces, inserting a `\` between each
|
|
126
|
+
// pair, so no `{{`/`}}` substring survives into the prompt: `{{a}}` → `{\{a\}\}`,
|
|
127
|
+
// and odd runs like `{{{a}}}` (which pair-wise escaping would leave with a
|
|
128
|
+
// literal `{{`) are handled too. The text keeps its readable template form,
|
|
129
|
+
// the transform is idempotent (escaped braces are single + `\`, never two
|
|
130
|
+
// adjacent), and single braces pass through untouched — interpolate only
|
|
131
|
+
// scans `{{`. Off via config.escapePromptVariables=false (default true).
|
|
132
|
+
function escapePromptVars(text) {
|
|
133
|
+
if (config.escapePromptVariables === false) return String(text);
|
|
134
|
+
return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
|
|
135
|
+
}
|
|
136
|
+
|
|
106
137
|
// Hot memory (v0.5.0 1.3): the latest rounds of THIS session, rebuilt from
|
|
107
138
|
// the materialized event log on every render — stateless, so it survives
|
|
108
139
|
// session switches and never persists anywhere.
|
|
@@ -119,7 +150,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
119
150
|
for (const r of rounds) hot.add(r);
|
|
120
151
|
const body = hot.getContext();
|
|
121
152
|
if (!body) return "";
|
|
122
|
-
return STR.hotHeader[language](rounds.length, body);
|
|
153
|
+
return escapePromptVars(STR.hotHeader[language](rounds.length, body));
|
|
123
154
|
}
|
|
124
155
|
|
|
125
156
|
function render(candidates) {
|
|
@@ -143,7 +174,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
143
174
|
lines.push(`- [${m.type}] ${verified}${title}`);
|
|
144
175
|
}
|
|
145
176
|
}
|
|
146
|
-
return lines.join("\n");
|
|
177
|
+
return escapePromptVars(lines.join("\n"));
|
|
147
178
|
}
|
|
148
179
|
|
|
149
180
|
// Bug4: the system-prompt render is synchronous, so the semantic query vector
|
|
@@ -177,7 +208,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
177
208
|
const lines = [STR.userSettingsHeader[language]];
|
|
178
209
|
if (profile) lines.push(STR.profileLine[language](profile));
|
|
179
210
|
for (const rule of rules) lines.push(STR.ruleLine[language](rule));
|
|
180
|
-
return lines.join("\n");
|
|
211
|
+
return escapePromptVars(lines.join("\n"));
|
|
181
212
|
}
|
|
182
213
|
|
|
183
214
|
const disposers = [
|
|
@@ -192,7 +223,10 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
192
223
|
const query = lastUserQuery(ctx);
|
|
193
224
|
if (query) prefetchQueryVector(query);
|
|
194
225
|
const queryVector = queryVectorCache.get(query);
|
|
195
|
-
|
|
226
|
+
// v0.8.0 A3:scope 随请求解析(strict 开启时 injectCandidates 内硬过滤;
|
|
227
|
+
// flag 关闭时解析器返回 null,注入行为不变)。
|
|
228
|
+
const scope = resolveSessionScope(ctx);
|
|
229
|
+
const candidates = service.injectCandidates({ query, queryVector, maxItems, threshold, scope });
|
|
196
230
|
// Hot memory (v0.5.0 1.3) leads the single memory block: the agent
|
|
197
231
|
// sees the short-term rounds first, then the cross-session recall —
|
|
198
232
|
// the documented injection order 1→2. Folding it here (instead of a
|
package/lib/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)})`
|
|
@@ -141,6 +152,18 @@ export const STR = {
|
|
|
141
152
|
zh: (name) => `自定义指令 ${name}`,
|
|
142
153
|
en: (name) => `Custom command ${name}`
|
|
143
154
|
},
|
|
155
|
+
commandInputHint: {
|
|
156
|
+
zh: "可选:追加给模型的补充说明",
|
|
157
|
+
en: "Optional: extra note to append for the model"
|
|
158
|
+
},
|
|
159
|
+
commandSubmitted: {
|
|
160
|
+
zh: (name) => `已提交指令:${name}`,
|
|
161
|
+
en: (name) => `Submitted command: ${name}`
|
|
162
|
+
},
|
|
163
|
+
commandSubmitFailed: {
|
|
164
|
+
zh: (name) => `指令 ${name} 提交失败,详见日志`,
|
|
165
|
+
en: (name) => `Failed to submit command ${name}; see logs`
|
|
166
|
+
},
|
|
144
167
|
|
|
145
168
|
// --- mirror.js:镜像文件标签(渲染随实例语言;解析两种语言都认) ----------------
|
|
146
169
|
mirrorLabel: {
|
package/lib/scope.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// v0.8.0 A1(issue #17):写入端 scope 解析。
|
|
2
|
+
//
|
|
3
|
+
// 身份模型(issue #17 的核心约定):session 只是「载体」,记忆挂在持久化身份
|
|
4
|
+
// 标签上——agent_scope = session header 的 agentPreset(跨会话稳定的 agent 模板
|
|
5
|
+
// 身份),workspace_scope = 会话工作目录(registry 反查的 canonical path 优先,
|
|
6
|
+
// header.cwd 兜底)。session_id 永不作为作用域键。
|
|
7
|
+
//
|
|
8
|
+
// 硬约束:解析失败绝不阻塞写入——任何一步取不到就落 NULL(读侧 NULL = 未标注
|
|
9
|
+
// = 全局可见),任何异常都被吞掉(registry 故障 warn 一次后降级)。
|
|
10
|
+
//
|
|
11
|
+
// v0.8.1(issue #170 复核项 2):scopeEnabled 的闸门从解析器挪到「写标注」处
|
|
12
|
+
// (memory_save 的 auto 分支)。解析器恒返回会话身份(真取不到才是 null)——
|
|
13
|
+
// 读取侧(strictVisibility / memory_get / inject)与显式声明都依赖它:flag 关
|
|
14
|
+
// 只关「自动标注」,不能连读取身份一起关,否则 strictScope 硬墙被静默拆掉
|
|
15
|
+
// (fail-open)。
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} deps
|
|
19
|
+
* @param {object} [deps.ctx] 宿主上下文(可能带 workspaceRegistry;旧版 dsh 没有)
|
|
20
|
+
* @param {object} [deps.config] 已解析配置(读 scopeEnabled)
|
|
21
|
+
* @param {object} [deps.logger]
|
|
22
|
+
* @returns {(exec: object) => {agent_scope: string|null, workspace_scope: string|null}|null}
|
|
23
|
+
*/
|
|
24
|
+
export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
25
|
+
let warned = false;
|
|
26
|
+
const warnOnce = (msg) => {
|
|
27
|
+
if (warned) return;
|
|
28
|
+
warned = true;
|
|
29
|
+
try {
|
|
30
|
+
logger?.warn?.(msg);
|
|
31
|
+
} catch {
|
|
32
|
+
// logger 自身故障同样不能影响写入路径
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return function resolveWriteScope(exec) {
|
|
37
|
+
let session = null;
|
|
38
|
+
try {
|
|
39
|
+
session = exec?.agent?.session ?? null;
|
|
40
|
+
} catch {
|
|
41
|
+
session = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 持久化 header(issue #17 的身份契约):session.header 是创建时冻结的
|
|
45
|
+
// SessionHeader(含 agentPreset/cwd)。⚠️ 不能先读 requestHeader()——那返回
|
|
46
|
+
// EpochHeader(请求级模型路由头,首个请求后即为真值),会把 session.header
|
|
47
|
+
// 遮蔽掉,agentPreset/cwd 双双取空(本地 web 复验 2026-09-13 实测踩中)。
|
|
48
|
+
// 仅当宿主没有 .header 属性时才降级尝试 requestHeader().config(旧形状)。
|
|
49
|
+
let header = null;
|
|
50
|
+
try {
|
|
51
|
+
header = session?.header ?? session?.requestHeader?.()?.config ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
header = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let agentScope = null;
|
|
57
|
+
const preset = header?.agentPreset;
|
|
58
|
+
if (typeof preset === "string" && preset.trim()) agentScope = preset.trim();
|
|
59
|
+
|
|
60
|
+
// workspace:registry 反查优先(成员资格 = id 命中 + header canonical cwd
|
|
61
|
+
// 校验过,path 已 realpath 归一),失败回退 header.cwd,再失败留 NULL。
|
|
62
|
+
let workspaceScope = null;
|
|
63
|
+
try {
|
|
64
|
+
const list = ctx?.workspaceRegistry?.list?.();
|
|
65
|
+
const sessionId = session?.id;
|
|
66
|
+
if (Array.isArray(list) && sessionId != null) {
|
|
67
|
+
const hit = list.find(
|
|
68
|
+
(ws) => Array.isArray(ws?.sessionIds) && ws.sessionIds.includes(sessionId)
|
|
69
|
+
);
|
|
70
|
+
if (typeof hit?.path === "string" && hit.path.trim()) workspaceScope = hit.path.trim();
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
warnOnce(
|
|
74
|
+
`[dsh-mneme] workspaceRegistry lookup failed, falling back to session header cwd: ${String(e)}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (!workspaceScope) {
|
|
78
|
+
const cwd = header?.cwd;
|
|
79
|
+
if (typeof cwd === "string" && cwd.trim()) workspaceScope = cwd.trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { agent_scope: agentScope, workspace_scope: workspaceScope };
|
|
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
|
+
}
|