@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/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
- const applyList = freezeEnabled ? decisions.filter((d) => d.action !== "conflict") : decisions;
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
- service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
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) => (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
@@ -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
+ }