@modusensus/dsh-mneme 0.7.32 → 0.8.0
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 +42 -20
- package/lib/api.js +60 -0
- package/lib/client.js +152 -2
- package/lib/commands.js +30 -3
- package/lib/config.js +21 -0
- package/lib/inject.js +39 -5
- package/lib/lang.js +12 -0
- package/lib/scope.js +83 -0
- package/lib/service.js +287 -62
- package/lib/settings.js +7 -0
- package/lib/store.js +100 -5
- package/lib/tools.js +86 -12
- package/package.json +1 -1
- package/src/api.js +60 -0
- package/src/commands.js +30 -3
- package/src/config.js +21 -0
- package/src/inject.js +39 -5
- package/src/lang.js +12 -0
- package/src/scope.js +83 -0
- package/src/service.js +287 -62
- package/src/settings.js +7 -0
- package/src/store.js +100 -5
- package/src/tools.js +86 -12
- package/test/api.test.js +90 -4
- package/test/client.test.js +58 -0
- package/test/commands.test.js +40 -3
- package/test/inject.test.js +93 -0
- package/test/scope-retrieval.test.js +197 -0
- package/test/scope-storage.test.js +247 -0
- package/test/scope-strict.test.js +165 -0
- package/test/service.test.js +76 -0
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
|
@@ -141,6 +141,18 @@ export const STR = {
|
|
|
141
141
|
zh: (name) => `自定义指令 ${name}`,
|
|
142
142
|
en: (name) => `Custom command ${name}`
|
|
143
143
|
},
|
|
144
|
+
commandInputHint: {
|
|
145
|
+
zh: "可选:追加给模型的补充说明",
|
|
146
|
+
en: "Optional: extra note to append for the model"
|
|
147
|
+
},
|
|
148
|
+
commandSubmitted: {
|
|
149
|
+
zh: (name) => `已提交指令:${name}`,
|
|
150
|
+
en: (name) => `Submitted command: ${name}`
|
|
151
|
+
},
|
|
152
|
+
commandSubmitFailed: {
|
|
153
|
+
zh: (name) => `指令 ${name} 提交失败,详见日志`,
|
|
154
|
+
en: (name) => `Failed to submit command ${name}; see logs`
|
|
155
|
+
},
|
|
144
156
|
|
|
145
157
|
// --- mirror.js:镜像文件标签(渲染随实例语言;解析两种语言都认) ----------------
|
|
146
158
|
mirrorLabel: {
|
package/lib/scope.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
// scopeEnabled 关闭时 resolveWriteScope 返回 null,调用方一个字段都不该标注,
|
|
12
|
+
// 写入与去重行为和 A1 之前逐字节一致。
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} deps
|
|
16
|
+
* @param {object} [deps.ctx] 宿主上下文(可能带 workspaceRegistry;旧版 dsh 没有)
|
|
17
|
+
* @param {object} [deps.config] 已解析配置(读 scopeEnabled)
|
|
18
|
+
* @param {object} [deps.logger]
|
|
19
|
+
* @returns {(exec: object) => {agent_scope: string|null, workspace_scope: string|null}|null}
|
|
20
|
+
*/
|
|
21
|
+
export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
22
|
+
let warned = false;
|
|
23
|
+
const warnOnce = (msg) => {
|
|
24
|
+
if (warned) return;
|
|
25
|
+
warned = true;
|
|
26
|
+
try {
|
|
27
|
+
logger?.warn?.(msg);
|
|
28
|
+
} catch {
|
|
29
|
+
// logger 自身故障同样不能影响写入路径
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return function resolveWriteScope(exec) {
|
|
34
|
+
if (config?.scopeEnabled !== true) return null;
|
|
35
|
+
|
|
36
|
+
let session = null;
|
|
37
|
+
try {
|
|
38
|
+
session = exec?.agent?.session ?? null;
|
|
39
|
+
} catch {
|
|
40
|
+
session = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 持久化 header(issue #17 的身份契约):session.header 是创建时冻结的
|
|
44
|
+
// SessionHeader(含 agentPreset/cwd)。⚠️ 不能先读 requestHeader()——那返回
|
|
45
|
+
// EpochHeader(请求级模型路由头,首个请求后即为真值),会把 session.header
|
|
46
|
+
// 遮蔽掉,agentPreset/cwd 双双取空(本地 web 复验 2026-09-13 实测踩中)。
|
|
47
|
+
// 仅当宿主没有 .header 属性时才降级尝试 requestHeader().config(旧形状)。
|
|
48
|
+
let header = null;
|
|
49
|
+
try {
|
|
50
|
+
header = session?.header ?? session?.requestHeader?.()?.config ?? null;
|
|
51
|
+
} catch {
|
|
52
|
+
header = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let agentScope = null;
|
|
56
|
+
const preset = header?.agentPreset;
|
|
57
|
+
if (typeof preset === "string" && preset.trim()) agentScope = preset.trim();
|
|
58
|
+
|
|
59
|
+
// workspace:registry 反查优先(成员资格 = id 命中 + header canonical cwd
|
|
60
|
+
// 校验过,path 已 realpath 归一),失败回退 header.cwd,再失败留 NULL。
|
|
61
|
+
let workspaceScope = null;
|
|
62
|
+
try {
|
|
63
|
+
const list = ctx?.workspaceRegistry?.list?.();
|
|
64
|
+
const sessionId = session?.id;
|
|
65
|
+
if (Array.isArray(list) && sessionId != null) {
|
|
66
|
+
const hit = list.find(
|
|
67
|
+
(ws) => Array.isArray(ws?.sessionIds) && ws.sessionIds.includes(sessionId)
|
|
68
|
+
);
|
|
69
|
+
if (typeof hit?.path === "string" && hit.path.trim()) workspaceScope = hit.path.trim();
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
warnOnce(
|
|
73
|
+
`[dsh-mneme] workspaceRegistry lookup failed, falling back to session header cwd: ${String(e)}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!workspaceScope) {
|
|
77
|
+
const cwd = header?.cwd;
|
|
78
|
+
if (typeof cwd === "string" && cwd.trim()) workspaceScope = cwd.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { agent_scope: agentScope, workspace_scope: workspaceScope };
|
|
82
|
+
};
|
|
83
|
+
}
|
package/lib/service.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { updatedAtBounds } from "./store.js";
|
|
3
4
|
import { STR, langOf } from "./lang.js";
|
|
4
5
|
import { computeHeat } from "./heat.js";
|
|
5
6
|
import { evaluateMemoryQuality } from "./quality-filter.js";
|
|
7
|
+
import { applyDecisions } from "./dream/decisions.js";
|
|
6
8
|
import { createBM25Index } from "./search/bm25.js";
|
|
7
9
|
import { adaptiveThreshold } from "./search/adaptive.js";
|
|
8
10
|
|
|
@@ -38,6 +40,66 @@ const CONTENT_HISTORY_MAX = 20;
|
|
|
38
40
|
// 清单必须与 quality-filter.js 的写入端对齐(6 个,含 type 自指的 self_referential)。
|
|
39
41
|
const SIGNAL_TAGS = ["low_quality", "duplicate", "meta", "repetitive", "short_content", "self_referential"];
|
|
40
42
|
|
|
43
|
+
// v0.8.0 A1(issue #17)去重键的 scope 归一:非空 trim 字符串原样比较,其余
|
|
44
|
+
// (NULL/undefined/空串)统一为「未标注」。与 store.normalizeScopeText 的落库
|
|
45
|
+
// 口径一致——两把钥匙只有都过这里再比较,NULL 与 'global' 才不会错配。
|
|
46
|
+
function scopeKeyOf(v) {
|
|
47
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// v0.8.0 A2(issue #17)scope 检索加权系数:两维都无法确立 foreign 的候选
|
|
51
|
+
// (命中行、未标注行、当前侧维度解析不到的行)→ 加成;只有确立了 foreign
|
|
52
|
+
// (记忆带标注 + 当前维度可解析 + 值不等)→ 降权但保留可见(硬过滤是 A3
|
|
53
|
+
// strictScope)。即未标注行与命中行同列、不吃惩罚,但注意它们吃的是 BOOST
|
|
54
|
+
// 而非 ×1 中性。系数是模块常量而非配置项:A2 只定性行为,数值要等线上检索
|
|
55
|
+
// 质量反馈再调(加配置面=提前优化)。
|
|
56
|
+
const SCOPE_MATCH_BOOST = 1.25;
|
|
57
|
+
const SCOPE_FOREIGN_PENALTY = 0.5;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 单条候选的 scope 乘数。匹配语义与去重键一致(scopeKeyOf):记忆侧未标注视同
|
|
61
|
+
* 该维度命中(全局记忆对谁都可见);当前会话侧某维度解析不到则该维度不参与
|
|
62
|
+
* 比较(无法比较 ≠ 不匹配)。
|
|
63
|
+
*/
|
|
64
|
+
function scopeMultiplier(m, current) {
|
|
65
|
+
const agentForeign = scopeKeyOf(m.agent_scope) !== null
|
|
66
|
+
&& current.agent_scope !== null
|
|
67
|
+
&& scopeKeyOf(m.agent_scope) !== current.agent_scope;
|
|
68
|
+
const workspaceForeign = scopeKeyOf(m.workspace_scope) !== null
|
|
69
|
+
&& current.workspace_scope !== null
|
|
70
|
+
&& scopeKeyOf(m.workspace_scope) !== current.workspace_scope;
|
|
71
|
+
return (agentForeign || workspaceForeign) ? SCOPE_FOREIGN_PENALTY : SCOPE_MATCH_BOOST;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* v0.8.0 A2:occurred_at 后置过滤谓词(搜索融合池用)。行侧取
|
|
76
|
+
* COALESCE(occurred_at, created_at)——与 store.list 的 SQL 过滤同口径,未标注
|
|
77
|
+
* 行回退创建时间;时间戳损坏的行保留(宁可放宽也不误杀,同 summarize inWindow)。
|
|
78
|
+
*/
|
|
79
|
+
function inOccurredBounds(m, bounds) {
|
|
80
|
+
const t = Date.parse(String(m.occurred_at ?? m.created_at ?? ""));
|
|
81
|
+
if (!Number.isFinite(t)) return true;
|
|
82
|
+
if (bounds.from && t < Date.parse(bounds.from)) return false;
|
|
83
|
+
if (bounds.to && t > Date.parse(bounds.to)) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* v0.8.0 A3(issue #17)strictScope 可见性谓词:issue 的四象限可见性公式
|
|
89
|
+
* (agent 不对称可见性)——记忆对当前会话可见 ⇔ (agent 维:未标注 或 命中当前
|
|
90
|
+
* agent) AND (workspace 维:未标注 或 命中当前 workspace)。NULL=未标注=全局;
|
|
91
|
+
* 当前会话某维度解析不到时,该维度带标注的记忆一律不可见(fail-closed:身份
|
|
92
|
+
* 不明的会话只见全局,不冒认)。sensitivity 是标签不参与可见性判定(其语义
|
|
93
|
+
* 留给后续批次)。store.list 的 SQL 过滤与此谓词同口径(见 store.js visibility)。
|
|
94
|
+
*/
|
|
95
|
+
function isVisibleInScope(m, current) {
|
|
96
|
+
const agent = scopeKeyOf(m.agent_scope);
|
|
97
|
+
if (agent !== null && (current.agent_scope === null || agent !== current.agent_scope)) return false;
|
|
98
|
+
const ws = scopeKeyOf(m.workspace_scope);
|
|
99
|
+
if (ws !== null && (current.workspace_scope === null || ws !== current.workspace_scope)) return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
41
103
|
/** Prepend the previous content to a memory's content_history (FIFO capped). */
|
|
42
104
|
function pushContentHistory(existing, source) {
|
|
43
105
|
const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
|
|
@@ -589,7 +651,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
589
651
|
* a vector/rerank failure degrades to keyword results.
|
|
590
652
|
*/
|
|
591
653
|
async function searchMemories(query, options = {}) {
|
|
592
|
-
const {
|
|
654
|
+
const {
|
|
655
|
+
mode = "auto",
|
|
656
|
+
topK = 20,
|
|
657
|
+
threshold,
|
|
658
|
+
useRerank = true,
|
|
659
|
+
// v0.8.0 A2(issue #17):当前会话 scope(工具层从 exec 解析后传入)与
|
|
660
|
+
// occurred_at 时间窗。两者都只在显式传入时生效,既有调用方(注入/梦/评测)
|
|
661
|
+
// 不传 → 行为与 A2 前一致。
|
|
662
|
+
scope = null,
|
|
663
|
+
occurredFrom = null,
|
|
664
|
+
occurredTo = null,
|
|
665
|
+
recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true)
|
|
666
|
+
} = options;
|
|
593
667
|
const q = String(query ?? "").trim();
|
|
594
668
|
if (!q) return [];
|
|
595
669
|
|
|
@@ -656,6 +730,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
656
730
|
|
|
657
731
|
const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb });
|
|
658
732
|
let merged = fusedMerged;
|
|
733
|
+
// v0.8.0 A3(issue #17):strictScope 硬过滤——他 scope 的候选直接出局
|
|
734
|
+
// (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权
|
|
735
|
+
// 叠加:过滤后剩下的命中行仍吃加成。scope 未传(flag 关)或完全解析不到
|
|
736
|
+
// 时跳过——identity 为空的对象({null,null})按 fail-closed 过滤。
|
|
737
|
+
if (config.strictScope === true && scope) {
|
|
738
|
+
merged = merged.filter((m) => isVisibleInScope(m, scope));
|
|
739
|
+
}
|
|
740
|
+
// v0.8.0 A2:occurred_at 时间过滤——在融合池上先滤再 dedup/slice,rerank
|
|
741
|
+
// 只看窗内候选,topK 槽位不被窗外行占用。
|
|
742
|
+
const occurredBounds = updatedAtBounds(occurredFrom, occurredTo);
|
|
743
|
+
if (occurredBounds.from || occurredBounds.to) {
|
|
744
|
+
merged = merged.filter((m) => inOccurredBounds(m, occurredBounds));
|
|
745
|
+
}
|
|
659
746
|
// Signal transparency (#2): decorate each returned row with its per-source
|
|
660
747
|
// scores and the final fused score. Purely additive — never changes rank.
|
|
661
748
|
if (config?.signalTransparency === true) {
|
|
@@ -685,6 +772,18 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
685
772
|
.slice(0, lim);
|
|
686
773
|
}
|
|
687
774
|
|
|
775
|
+
// v0.8.0 A2(issue #17):scope 检索加权——两维都无法确立 foreign 的候选
|
|
776
|
+
// (命中/未标注/当前侧解析不到)加成,确立 foreign 的候选降权但保留可见
|
|
777
|
+
// (硬过滤是 A3 strictScope);未标注行与命中行同列吃 BOOST、不被压制。
|
|
778
|
+
// flag 关或调用方未传 scope 时不动分,排序与 A2 前逐字节一致。与
|
|
779
|
+
// epistemic 加权同款收尾:乘分 → 降序 → 截 topK。
|
|
780
|
+
if (config.scopeEnabled === true && scope && (scope.agent_scope || scope.workspace_scope)) {
|
|
781
|
+
result = result
|
|
782
|
+
.map((m) => ({ ...m, score: (m.score ?? 0) * scopeMultiplier(m, scope) }))
|
|
783
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
784
|
+
.slice(0, lim);
|
|
785
|
+
}
|
|
786
|
+
|
|
688
787
|
// Recall layer receipt: with recordRecall on, hand the actual merged
|
|
689
788
|
// candidate list (id/title/content/score/source) to the injected recorder
|
|
690
789
|
// before returning, making the retrieval scene replayable — the sibling of
|
|
@@ -965,15 +1064,28 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
965
1064
|
// 标题变化,若仍按 (type, title) 匹配会出现两个活跃的 summary 同时注入。
|
|
966
1065
|
// 其余类型维持 (type, title) 匹配不变。
|
|
967
1066
|
const candidates = store.list({ type: memory.type, limit: 100 });
|
|
1067
|
+
// v0.8.0 A1(issue #17):scopeEnabled 开启时去重键由 (type, title) 扩展为
|
|
1068
|
+
// (type, title, agent_scope, workspace_scope, sensitivity)——不同作用域的同
|
|
1069
|
+
// 标题记忆绝不互相物理合并(旧逻辑跨 agent/workspace 并行,事后无法拆分)。
|
|
1070
|
+
// NULL 归一为「未标注」:未标注行之间互相匹配(含存量行),与已标注行不
|
|
1071
|
+
// 匹配。flag 关闭时跳过比较,行为与 A1 前逐字节一致。
|
|
1072
|
+
const scopeOn = config.scopeEnabled === true;
|
|
1073
|
+
const scopeMatches = (m) =>
|
|
1074
|
+
!scopeOn ||
|
|
1075
|
+
(scopeKeyOf(m.agent_scope) === scopeKeyOf(memory.agent_scope) &&
|
|
1076
|
+
scopeKeyOf(m.workspace_scope) === scopeKeyOf(memory.workspace_scope) &&
|
|
1077
|
+
scopeKeyOf(m.sensitivity) === scopeKeyOf(memory.sensitivity));
|
|
968
1078
|
// Issue #127:写入端语义去重命中时,调用方用 _mergeInto 指定并入目标——复用
|
|
969
1079
|
// 下面这段并入逻辑(appendContent + content_history + 质量处置),不另写第二份
|
|
970
|
-
//
|
|
971
|
-
|
|
972
|
-
|
|
1080
|
+
// 实现。目标已被并发删除时回落到常规匹配。显式目标同样过 scope 门:跨作用域
|
|
1081
|
+
// 的语义相似不并入(防泄漏),回落为独立新行。
|
|
1082
|
+
const explicitTarget = memory._mergeInto ? store.getById(memory._mergeInto) : undefined;
|
|
1083
|
+
const existing = explicitTarget
|
|
1084
|
+
? (scopeMatches(explicitTarget) ? explicitTarget : undefined)
|
|
973
1085
|
: (memory.type === "summary" && memory.source === "dream")
|
|
974
|
-
? (candidates.find((m) => m.source === "dream")
|
|
975
|
-
?? candidates.find((m) => m.title.trim() === String(memory.title).trim()))
|
|
976
|
-
: candidates.find((m) => m.title.trim() === String(memory.title).trim());
|
|
1086
|
+
? (candidates.find((m) => m.source === "dream" && scopeMatches(m))
|
|
1087
|
+
?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()))
|
|
1088
|
+
: candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim());
|
|
977
1089
|
if (existing) {
|
|
978
1090
|
const newContent = String(memory.content ?? "");
|
|
979
1091
|
if (!newContent.trim()) {
|
|
@@ -1009,6 +1121,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1009
1121
|
tags: memory.tags ?? [],
|
|
1010
1122
|
importance: memory.importance ?? 3,
|
|
1011
1123
|
source: memory.source ?? "manual",
|
|
1124
|
+
// v0.8.0 A1:scope 标注透传(store 端归一化,未标注落 NULL)。
|
|
1125
|
+
agent_scope: memory.agent_scope,
|
|
1126
|
+
workspace_scope: memory.workspace_scope,
|
|
1127
|
+
sensitivity: memory.sensitivity,
|
|
1128
|
+
occurred_at: memory.occurred_at,
|
|
1012
1129
|
...(quality ? { quality_score: quality.score } : {})
|
|
1013
1130
|
});
|
|
1014
1131
|
const result = applyQualityDisposition(created, quality, qf);
|
|
@@ -1074,7 +1191,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1074
1191
|
* fills + dedupes the remaining slots. Empty query / no cached recall /
|
|
1075
1192
|
* hybridInject off → pure legacy rule-based selection.
|
|
1076
1193
|
*/
|
|
1077
|
-
function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector } = {}) {
|
|
1194
|
+
function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector, scope = null } = {}) {
|
|
1078
1195
|
const q = String(query ?? "").trim();
|
|
1079
1196
|
// codingRetrospect 读取侧门控:编码记忆(rejected_solution / pitfall /
|
|
1080
1197
|
// constraint)只在编码任务时注入,防噪声污染其他业务;编码任务时按
|
|
@@ -1164,6 +1281,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1164
1281
|
}
|
|
1165
1282
|
} catch { /* topic re-rank unavailable: keep rule-based order */ }
|
|
1166
1283
|
}
|
|
1284
|
+
// v0.8.0 A3(issue #17):strictScope 硬过滤同样作用于自动注入——scoped 记忆
|
|
1285
|
+
// 泄进无关注入上下文是最典型的越权通道,检索侧过滤挡不住这里。
|
|
1286
|
+
if (config?.strictScope === true && scope) {
|
|
1287
|
+
candidates = candidates.filter((m) => isVisibleInScope(m, scope));
|
|
1288
|
+
}
|
|
1167
1289
|
const selected = candidates.slice(0, maxItems);
|
|
1168
1290
|
touchRecalled(selected);
|
|
1169
1291
|
return selected;
|
|
@@ -1217,6 +1339,94 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1217
1339
|
return applied;
|
|
1218
1340
|
}
|
|
1219
1341
|
|
|
1342
|
+
/**
|
|
1343
|
+
* v0.8.0 冲突集中处理(issue 反馈:冻结冲突没有人工出口——store 的
|
|
1344
|
+
* resolveConflictPending 此前无任何 API/UI 调用)。人工确认一条待审冲突:
|
|
1345
|
+
* 盖章 resolved_at/resolved_winner(审计),可选按 dream 非冻结 conflict
|
|
1346
|
+
* 路径**同款**处置落地——复用 applyDecisions 的 conflict 分支,CAS 快照 +
|
|
1347
|
+
* 事务 + 胜者追加已否决注记 + 败者归档 + 幂等全部同源,不另写第二份裁决。
|
|
1348
|
+
*
|
|
1349
|
+
* @param {string} id conflict_pending 行 id
|
|
1350
|
+
* @param {{winner?: "a"|"b"|null, apply?: boolean}} opts
|
|
1351
|
+
* winner: 选哪一方保留(a = memory_a,冻结时 LLM 的建议胜者;b = memory_b);
|
|
1352
|
+
* null = 仅盖章标记已读、不落地处置。人工选择是权威裁决:传入
|
|
1353
|
+
* applyDecisions 的 config 关掉 trustEpistemicWeighting——可信度自动换位
|
|
1354
|
+
* 只应作用于 LLM 提议对,不能推翻人工选择。
|
|
1355
|
+
* @returns {object|undefined} 盖章后的冲突行 + disposition 摘要;未知 id 返回 undefined
|
|
1356
|
+
*/
|
|
1357
|
+
function resolveConflictPending(id, { winner = null, apply = true } = {}) {
|
|
1358
|
+
const rows = store.listConflictPending({ includeResolved: true, limit: 500 });
|
|
1359
|
+
const row = rows.find((r) => r.id === id);
|
|
1360
|
+
if (!row) return undefined;
|
|
1361
|
+
const winnerId = winner === "a" ? row.memory_a : winner === "b" ? row.memory_b : null;
|
|
1362
|
+
const loserId = winner === "a" ? row.memory_b : winner === "b" ? row.memory_a : null;
|
|
1363
|
+
let disposition = null;
|
|
1364
|
+
if (apply && winnerId && loserId) {
|
|
1365
|
+
const snapshot = new Map();
|
|
1366
|
+
for (const m of [store.getById(row.memory_a), store.getById(row.memory_b)]) {
|
|
1367
|
+
if (m) snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
|
|
1368
|
+
}
|
|
1369
|
+
// applyDecisions 需要一个 service 形状的参数(getById/update/setArchived/
|
|
1370
|
+
// transaction)——委托给闭包内的同名实现,mirror/通知/嵌入与主写路径同源。
|
|
1371
|
+
const applyService = {
|
|
1372
|
+
getById: (mid) => store.getById(mid),
|
|
1373
|
+
update: (mid, patch) => updateMemory(mid, patch),
|
|
1374
|
+
setArchived: (mid, v) => archiveMemory(mid, v),
|
|
1375
|
+
transaction: (fn) => transaction(fn)
|
|
1376
|
+
};
|
|
1377
|
+
const { committed, failures } = applyDecisions(
|
|
1378
|
+
[{ action: "conflict", winner: winnerId, loser: loserId, reason: row.reason ?? "" }],
|
|
1379
|
+
applyService,
|
|
1380
|
+
logger,
|
|
1381
|
+
snapshot,
|
|
1382
|
+
{ ...config, trustEpistemicWeighting: false }
|
|
1383
|
+
);
|
|
1384
|
+
// Review 发现:处置必须**先于**盖章。原实现先 resolveConflictPending 盖章、
|
|
1385
|
+
// 再 applyDecisions——若 CAS 拒掉(确认前双方被并发更新),行已 resolved 从
|
|
1386
|
+
// 队列消失、前端仍显示成功,审计却记了"已确认"且无法重试。现在失败时不盖章
|
|
1387
|
+
// 直接返回:行留在队列待重试,审计不被污染。
|
|
1388
|
+
if (failures.length) {
|
|
1389
|
+
return { ...row, disposition: { ok: false, failures } };
|
|
1390
|
+
}
|
|
1391
|
+
disposition = { ok: true, committed };
|
|
1392
|
+
}
|
|
1393
|
+
// 处置成功(或纯标记)后才盖章。幂等重放(败者已归档 → skipped)也走这里:
|
|
1394
|
+
// 处置本就存在,标记已审即可。
|
|
1395
|
+
const stamped = store.resolveConflictPending(id, { winner: winnerId });
|
|
1396
|
+
if (!stamped) return undefined;
|
|
1397
|
+
return { ...stamped, disposition };
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* v0.8.0 冲突队列视图数据源:未解决冲突行,联表带出双方的当前状态(标题/
|
|
1402
|
+
* 内容/类型/重要性/归档标记),面板一处即可集中审阅,不必按徽章逐条找。
|
|
1403
|
+
* 参与记忆被删除/失联时该侧返回 {missing: true}(队列不因此丢行)。
|
|
1404
|
+
*/
|
|
1405
|
+
function listConflictQueue({ limit = 50, offset = 0 } = {}) {
|
|
1406
|
+
const rows = store.listConflictPending({ limit, offset, includeResolved: false });
|
|
1407
|
+
const side = (id) => {
|
|
1408
|
+
const m = store.getById(id);
|
|
1409
|
+
if (!m) return { id, missing: true };
|
|
1410
|
+
return {
|
|
1411
|
+
id,
|
|
1412
|
+
title: m.title,
|
|
1413
|
+
type: m.type,
|
|
1414
|
+
importance: m.importance,
|
|
1415
|
+
content: m.content,
|
|
1416
|
+
archived: m.archived === true,
|
|
1417
|
+
forgotten: m.forgotten === true
|
|
1418
|
+
};
|
|
1419
|
+
};
|
|
1420
|
+
return rows.map((r) => ({
|
|
1421
|
+
id: r.id,
|
|
1422
|
+
run_id: r.run_id ?? null,
|
|
1423
|
+
reason: r.reason ?? null,
|
|
1424
|
+
created_at: r.created_at,
|
|
1425
|
+
memory_a: side(r.memory_a),
|
|
1426
|
+
memory_b: side(r.memory_b)
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1220
1430
|
function toApiList(rows) {
|
|
1221
1431
|
return rows.map((m) => ({
|
|
1222
1432
|
id: m.id,
|
|
@@ -1227,7 +1437,14 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1227
1437
|
importance: m.importance,
|
|
1228
1438
|
source: m.source,
|
|
1229
1439
|
created_at: m.created_at,
|
|
1230
|
-
updated_at: m.updated_at
|
|
1440
|
+
updated_at: m.updated_at,
|
|
1441
|
+
// v0.8.0 A2:scope 标注与事件发生时间透出——条件展开(未标注行不带键,
|
|
1442
|
+
// DTO 与 A2 前逐字节同形;带 undefined 键会被 in-process schema 校验
|
|
1443
|
+
// 判违规,JSON 序列化虽会丢弃但形状不稳定)。
|
|
1444
|
+
...(m.agent_scope !== undefined ? { agent_scope: m.agent_scope } : {}),
|
|
1445
|
+
...(m.workspace_scope !== undefined ? { workspace_scope: m.workspace_scope } : {}),
|
|
1446
|
+
...(m.sensitivity !== undefined ? { sensitivity: m.sensitivity } : {}),
|
|
1447
|
+
...(m.occurred_at !== undefined ? { occurred_at: m.occurred_at } : {})
|
|
1231
1448
|
}));
|
|
1232
1449
|
}
|
|
1233
1450
|
|
|
@@ -1508,6 +1725,62 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1508
1725
|
}
|
|
1509
1726
|
}
|
|
1510
1727
|
|
|
1728
|
+
// 返回字面量与内部裁决器(resolveConflictPending → applyDecisions 的 conflict
|
|
1729
|
+
// 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
|
|
1730
|
+
function updateMemory(id, p, ctx = {}) {
|
|
1731
|
+
const old = store.getById(id);
|
|
1732
|
+
// Issue #135 附属发现 2:质量过滤器把「为什么被降权/归档」写在系统信号标签
|
|
1733
|
+
// 上(SIGNAL_TAGS,applyQualityDisposition 以并集写入),而这里整组替换
|
|
1734
|
+
// tags——任何带 tags 的更新都会抹掉这条唯一的审计线索(报告实测 150 条低分
|
|
1735
|
+
// 记忆里恰好缺的 2 条就是被 update 过的,并因此误判过归档来源)。把 existing
|
|
1736
|
+
// 的系统信号标签并集保留;用户自传的标签照常生效。
|
|
1737
|
+
const patch = (p && Array.isArray(p.tags) && old && Array.isArray(old.tags))
|
|
1738
|
+
? { ...p, tags: [...new Set([...p.tags, ...old.tags.filter((t) => SIGNAL_TAGS.includes(t))])] }
|
|
1739
|
+
: p;
|
|
1740
|
+
const updated = store.update(id, patch);
|
|
1741
|
+
// Record a user correction when any meaningful field changed and the
|
|
1742
|
+
// reflection failure tracker is enabled. expected = what it became,
|
|
1743
|
+
// actual = what it was before; query (when provided) captures the
|
|
1744
|
+
// user's original intent so later reflection can reason about recall.
|
|
1745
|
+
const hasMeaningfulChange = old && updated && (
|
|
1746
|
+
old.content !== updated.content ||
|
|
1747
|
+
old.title !== updated.title ||
|
|
1748
|
+
old.importance !== updated.importance
|
|
1749
|
+
);
|
|
1750
|
+
if (hasMeaningfulChange && config.reflectionFailureTracking) {
|
|
1751
|
+
store.saveFailure({
|
|
1752
|
+
id: randomUUID(),
|
|
1753
|
+
query: ctx.query ?? null,
|
|
1754
|
+
expected: updated.content,
|
|
1755
|
+
actual: old.content,
|
|
1756
|
+
before: { title: old.title, content: old.content, importance: old.importance },
|
|
1757
|
+
failure_type: "user_correction",
|
|
1758
|
+
memory_id: id
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
const sync = afterSync("write");
|
|
1762
|
+
notifyWrite();
|
|
1763
|
+
scheduleEmbed(updated);
|
|
1764
|
+
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
1765
|
+
// the mirror did not converge — return an explicit degraded receipt rather
|
|
1766
|
+
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
1767
|
+
// the memory shape keep passing.
|
|
1768
|
+
if (!sync?.success && !sync?.deferred) {
|
|
1769
|
+
Object.defineProperty(updated, "_mirror", {
|
|
1770
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
1771
|
+
enumerable: false,
|
|
1772
|
+
configurable: true
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
return updated;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function archiveMemory(id, f) {
|
|
1779
|
+
const updated = store.setArchived(id, f);
|
|
1780
|
+
afterSync("write");
|
|
1781
|
+
return updated;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1511
1784
|
return {
|
|
1512
1785
|
saveWithDedupe,
|
|
1513
1786
|
recoverMirror,
|
|
@@ -1516,6 +1789,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1516
1789
|
injectCandidates,
|
|
1517
1790
|
mergeHumanEdits,
|
|
1518
1791
|
toApiList,
|
|
1792
|
+
isVisibleInScope,
|
|
1519
1793
|
transaction,
|
|
1520
1794
|
enqueue,
|
|
1521
1795
|
setDreamHook(fn) { dreamHook = fn; },
|
|
@@ -1568,53 +1842,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1568
1842
|
afterSync("write");
|
|
1569
1843
|
notifyWrite();
|
|
1570
1844
|
},
|
|
1571
|
-
update:
|
|
1572
|
-
const old = store.getById(id);
|
|
1573
|
-
// Issue #135 附属发现 2:质量过滤器把「为什么被降权/归档」写在系统信号标签
|
|
1574
|
-
// 上(SIGNAL_TAGS,applyQualityDisposition 以并集写入),而这里整组替换
|
|
1575
|
-
// tags——任何带 tags 的更新都会抹掉这条唯一的审计线索(报告实测 150 条低分
|
|
1576
|
-
// 记忆里恰好缺的 2 条就是被 update 过的,并因此误判过归档来源)。把 existing
|
|
1577
|
-
// 的系统信号标签并集保留;用户自传的标签照常生效。
|
|
1578
|
-
const patch = (p && Array.isArray(p.tags) && old && Array.isArray(old.tags))
|
|
1579
|
-
? { ...p, tags: [...new Set([...p.tags, ...old.tags.filter((t) => SIGNAL_TAGS.includes(t))])] }
|
|
1580
|
-
: p;
|
|
1581
|
-
const updated = store.update(id, patch);
|
|
1582
|
-
// Record a user correction when any meaningful field changed and the
|
|
1583
|
-
// reflection failure tracker is enabled. expected = what it became,
|
|
1584
|
-
// actual = what it was before; query (when provided) captures the
|
|
1585
|
-
// user's original intent so later reflection can reason about recall.
|
|
1586
|
-
const hasMeaningfulChange = old && updated && (
|
|
1587
|
-
old.content !== updated.content ||
|
|
1588
|
-
old.title !== updated.title ||
|
|
1589
|
-
old.importance !== updated.importance
|
|
1590
|
-
);
|
|
1591
|
-
if (hasMeaningfulChange && config.reflectionFailureTracking) {
|
|
1592
|
-
store.saveFailure({
|
|
1593
|
-
id: randomUUID(),
|
|
1594
|
-
query: ctx.query ?? null,
|
|
1595
|
-
expected: updated.content,
|
|
1596
|
-
actual: old.content,
|
|
1597
|
-
before: { title: old.title, content: old.content, importance: old.importance },
|
|
1598
|
-
failure_type: "user_correction",
|
|
1599
|
-
memory_id: id
|
|
1600
|
-
});
|
|
1601
|
-
}
|
|
1602
|
-
const sync = afterSync("write");
|
|
1603
|
-
notifyWrite();
|
|
1604
|
-
scheduleEmbed(updated);
|
|
1605
|
-
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
1606
|
-
// the mirror did not converge — return an explicit degraded receipt rather
|
|
1607
|
-
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
1608
|
-
// the memory shape keep passing.
|
|
1609
|
-
if (!sync?.success && !sync?.deferred) {
|
|
1610
|
-
Object.defineProperty(updated, "_mirror", {
|
|
1611
|
-
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
1612
|
-
enumerable: false,
|
|
1613
|
-
configurable: true
|
|
1614
|
-
});
|
|
1615
|
-
}
|
|
1616
|
-
return updated;
|
|
1617
|
-
},
|
|
1845
|
+
update: updateMemory,
|
|
1618
1846
|
// Compare-and-set update: applies the patch only when the row still carries
|
|
1619
1847
|
// `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
|
|
1620
1848
|
// can re-read and retry — the primitive that prevents lost updates across
|
|
@@ -1657,11 +1885,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1657
1885
|
afterSync("write");
|
|
1658
1886
|
return updated;
|
|
1659
1887
|
},
|
|
1660
|
-
setArchived:
|
|
1661
|
-
const updated = store.setArchived(id, f);
|
|
1662
|
-
afterSync("write");
|
|
1663
|
-
return updated;
|
|
1664
|
-
},
|
|
1888
|
+
setArchived: archiveMemory,
|
|
1665
1889
|
// sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
|
|
1666
1890
|
// content so they ride the normal write-hook path (mirror re-renders).
|
|
1667
1891
|
// touchLastAccess is a read-stamp — deliberately NO write hook (a recall
|
|
@@ -1693,7 +1917,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1693
1917
|
// above: an audit write, never a write-hook-triggering memory mutation).
|
|
1694
1918
|
saveConflictPending: (r) => store.saveConflictPending(r),
|
|
1695
1919
|
listConflictPending: (opts) => store.listConflictPending(opts),
|
|
1696
|
-
|
|
1920
|
+
listConflictQueue,
|
|
1921
|
+
resolveConflictPending,
|
|
1697
1922
|
countConflictPending: () => store.countConflictPending(),
|
|
1698
1923
|
// Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
|
|
1699
1924
|
// dream/recall passthroughs above — a recall_evals write is a snapshot, not
|