@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/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) => (typeof p === "string" ? p : p?.text ?? ""))
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
- const candidates = service.injectCandidates({ query, queryVector, maxItems, threshold });
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 { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true) } = options;
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
- const existing = memory._mergeInto
972
- ? store.getById(memory._mergeInto)
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: (id, p, ctx = {}) => {
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: (id, f) => {
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
- resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
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