@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/src/settings.js CHANGED
@@ -64,6 +64,13 @@ const FEATURE_FLAG_BOOLEANS = [
64
64
  // Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
65
65
  "dreamSkipInvalid",
66
66
  "allowCrossTypeMerge",
67
+ // Issue #17(v0.8.0 A1):scope 隔离存储层总开关。开启后写入标注
68
+ // agent_scope/workspace_scope、去重键扩展(含 sensitivity);检索侧过滤在
69
+ // A2/A3。默认关=行为与 A1 前逐字节一致。
70
+ "scopeEnabled",
71
+ // Issue #17(v0.8.0 A3):strictScope 硬过滤——他 scope 完全不可见(关闭时
72
+ // 为 A2 软隔离:降权保留可见)。
73
+ "strictScope",
67
74
  // 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
68
75
  // 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
69
76
  // 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
package/src/store.js CHANGED
@@ -310,7 +310,9 @@ function escapeLike(q) {
310
310
  // 法值一律返回 undefined → 不进 WHERE(忽略而非报错:面板传坏参数时宁可放宽
311
311
  // 过滤也不要白屏)。updated_at 列是 toISOString 产生的 UTC "Z" 字符串,字典
312
312
  // 序与时间序一致,SQL 里可直接比较。
313
- function updatedAtBounds(updatedFrom, updatedTo) {
313
+ // v0.8.0 A2:导出复用——occurred_at 时间过滤(service 的搜索后置过滤)与
314
+ // updated_at 过滤共用同一套边界归一化,坏参数口径一致。
315
+ export function updatedAtBounds(updatedFrom, updatedTo) {
314
316
  const norm = (raw, endOfDay) => {
315
317
  if (typeof raw !== "string" || !raw.trim()) return undefined;
316
318
  const s = raw.trim();
@@ -332,6 +334,26 @@ function parseTags(raw) {
332
334
  }
333
335
  }
334
336
 
337
+ // scope 列(agent_scope/workspace_scope/sensitivity)写入归一化:非空 trim 字符串
338
+ // 原样收下,其余(undefined/null/空串/非字符串)一律 NULL。存储层不做枚举校验——
339
+ // scope 值是宿主身份标签(agentPreset id / 目录路径 / 自由 sensitivity 标签),
340
+ // 语义由调用方负责;这里只保证「脏输入落 NULL,绝不阻塞写入」。
341
+ function normalizeScopeText(raw) {
342
+ if (typeof raw !== "string") return null;
343
+ const s = raw.trim();
344
+ return s ? s : null;
345
+ }
346
+
347
+ // occurred_at:事件发生时间(区别于 created_at 的入库时间)。可解析的时间戳
348
+ // 统一归一到 UTC ISO(A2 的时间过滤要按字典序直接比较);解析失败落 NULL,
349
+ // 同样不阻塞写入。
350
+ function normalizeOccurredAt(raw) {
351
+ if (typeof raw !== "string" || !raw.trim()) return null;
352
+ const ms = Date.parse(raw.trim());
353
+ if (Number.isNaN(ms)) return null;
354
+ return new Date(ms).toISOString();
355
+ }
356
+
335
357
  function toRow(row) {
336
358
  if (!row) return undefined;
337
359
  return {
@@ -347,6 +369,10 @@ function toRow(row) {
347
369
  content_history: parseJsonArray(row.content_history),
348
370
  quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
349
371
  epistemic_status: row.epistemic_status ?? "subjective",
372
+ agent_scope: row.agent_scope ?? undefined,
373
+ workspace_scope: row.workspace_scope ?? undefined,
374
+ sensitivity: row.sensitivity ?? undefined,
375
+ occurred_at: row.occurred_at ?? undefined,
350
376
  created_at: row.created_at,
351
377
  updated_at: row.updated_at,
352
378
  last_accessed_at: row.last_accessed_at ?? undefined,
@@ -597,6 +623,14 @@ export function createStore(path) {
597
623
  addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
598
624
  addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
599
625
 
626
+ // v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行
627
+ // 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控
628
+ // (service/tools 层),存储层只负责归一化(normalizeScopeText/normalizeOccurredAt)。
629
+ addColumn("memories", "agent_scope", "ALTER TABLE memories ADD COLUMN agent_scope TEXT");
630
+ addColumn("memories", "workspace_scope", "ALTER TABLE memories ADD COLUMN workspace_scope TEXT");
631
+ addColumn("memories", "sensitivity", "ALTER TABLE memories ADD COLUMN sensitivity TEXT");
632
+ addColumn("memories", "occurred_at", "ALTER TABLE memories ADD COLUMN occurred_at TEXT");
633
+
600
634
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
601
635
  addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
602
636
  addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
@@ -643,7 +677,7 @@ export function createStore(path) {
643
677
  return ts;
644
678
  }
645
679
 
646
- function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null } = {}) {
680
+ function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
647
681
  const clauses = [];
648
682
  const params = [];
649
683
  if (type !== undefined) {
@@ -670,6 +704,33 @@ export function createStore(path) {
670
704
  clauses.push("updated_at <= ?");
671
705
  params.push(bounds.to);
672
706
  }
707
+ // occurred_at 闭区间:与 list() 同口径(COALESCE 回退 created_at),
708
+ // memory_list 的 total 才能和过滤后的行保持一致。
709
+ const occurred = updatedAtBounds(occurredFrom, occurredTo);
710
+ if (occurred.from) {
711
+ clauses.push("COALESCE(occurred_at, created_at) >= ?");
712
+ params.push(occurred.from);
713
+ }
714
+ if (occurred.to) {
715
+ clauses.push("COALESCE(occurred_at, created_at) <= ?");
716
+ params.push(occurred.to);
717
+ }
718
+ // occurred_at 闭区间:与 list() 同过滤(visibility 同口径),memory_list
719
+ // 的 total 才能和过滤后的行保持一致。
720
+ if (visibility) {
721
+ if (visibility.agentScope != null) {
722
+ clauses.push("(agent_scope IS NULL OR agent_scope = ?)");
723
+ params.push(visibility.agentScope);
724
+ } else {
725
+ clauses.push("agent_scope IS NULL");
726
+ }
727
+ if (visibility.workspaceScope != null) {
728
+ clauses.push("(workspace_scope IS NULL OR workspace_scope = ?)");
729
+ params.push(visibility.workspaceScope);
730
+ } else {
731
+ clauses.push("workspace_scope IS NULL");
732
+ }
733
+ }
673
734
  if (!includeForgotten) {
674
735
  clauses.push("forgotten = 0");
675
736
  }
@@ -714,8 +775,8 @@ export function createStore(path) {
714
775
  : inferEpistemicStatus(memory);
715
776
  runAtomically(() => {
716
777
  db.prepare(
717
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
718
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
778
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, sensitivity, occurred_at, created_at, updated_at)
779
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
719
780
  ).run(
720
781
  id,
721
782
  type,
@@ -729,6 +790,10 @@ export function createStore(path) {
729
790
  Number.isFinite(memory.quality_score) ? memory.quality_score : null,
730
791
  embedding,
731
792
  epistemicStatus,
793
+ normalizeScopeText(memory.agent_scope),
794
+ normalizeScopeText(memory.workspace_scope),
795
+ normalizeScopeText(memory.sensitivity),
796
+ normalizeOccurredAt(memory.occurred_at),
732
797
  now,
733
798
  now
734
799
  );
@@ -936,7 +1001,7 @@ export function createStore(path) {
936
1001
  return rows.map(toRow);
937
1002
  }
938
1003
 
939
- function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
1004
+ function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
940
1005
  const clauses = [];
941
1006
  const params = [];
942
1007
  if (type) {
@@ -964,6 +1029,36 @@ export function createStore(path) {
964
1029
  clauses.push("updated_at <= ?");
965
1030
  params.push(bounds.to);
966
1031
  }
1032
+ // v0.8.0 A2(issue #17):occurred_at 闭区间过滤——按「事件发生时间」检索。
1033
+ // 未标注 occurred_at 的行(含全部存量)回退 created_at 比较(COALESCE),
1034
+ // 否则过滤器对旧库近乎不可用;边界归一化与 updated_at 共用 updatedAtBounds。
1035
+ const occurred = updatedAtBounds(occurredFrom, occurredTo);
1036
+ if (occurred.from) {
1037
+ clauses.push("COALESCE(occurred_at, created_at) >= ?");
1038
+ params.push(occurred.from);
1039
+ }
1040
+ if (occurred.to) {
1041
+ clauses.push("COALESCE(occurred_at, created_at) <= ?");
1042
+ params.push(occurred.to);
1043
+ }
1044
+ // v0.8.0 A3(issue #17):strictScope 硬过滤(memory_list 分页路径)。
1045
+ // SQL 与 service.isVisibleInScope 同口径:未标注(NULL)恒可见;当前维度
1046
+ // 解析不到时该维度只放行未标注行(fail-closed)。只有显式传 visibility
1047
+ // 的用户面调用才过滤——dream/质量过滤等内部 store.list 调用不受影响。
1048
+ if (visibility) {
1049
+ if (visibility.agentScope != null) {
1050
+ clauses.push("(agent_scope IS NULL OR agent_scope = ?)");
1051
+ params.push(visibility.agentScope);
1052
+ } else {
1053
+ clauses.push("agent_scope IS NULL");
1054
+ }
1055
+ if (visibility.workspaceScope != null) {
1056
+ clauses.push("(workspace_scope IS NULL OR workspace_scope = ?)");
1057
+ params.push(visibility.workspaceScope);
1058
+ } else {
1059
+ clauses.push("workspace_scope IS NULL");
1060
+ }
1061
+ }
967
1062
  if (!includeForgotten) {
968
1063
  clauses.push("forgotten = 0");
969
1064
  }
package/src/tools.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
+ import { createScopeResolver } from "./scope.js";
2
3
  import { describeLocalRuntime, resolveRuntimeEntry } from "./runtime/loader.js";
3
4
  import { defaultRuntimeDir } from "./runtime/layout.js";
4
5
  import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
@@ -25,11 +26,44 @@ const MEMORY_ITEM_SCHEMA = {
25
26
  tags: { type: "array", items: { type: "string" } },
26
27
  importance: { type: "integer", required: true },
27
28
  source: { type: "string" },
29
+ // v0.8.0 A2:scope 标注与事件发生时间随行透出(未标注时缺省,可选属性)。
30
+ agent_scope: { type: "string" },
31
+ workspace_scope: { type: "string" },
32
+ sensitivity: { type: "string" },
33
+ occurred_at: { type: "string" },
28
34
  created_at: { type: "string", required: true },
29
35
  updated_at: { type: "string", required: true }
30
36
  }
31
37
  };
32
38
 
39
+ // v0.8.0 A2:occurred 时间窗参数 → store 过滤选项(list/count 同口径);
40
+ // 未传参返回空对象,既有调用方不受影响。(A3 复核:此处入参仅含 occurred 边界。)
41
+ function occurredWindow(args) {
42
+ const from = args?.occurred_from ?? null;
43
+ const to = args?.occurred_to ?? null;
44
+ return from !== null || to !== null ? { occurredFrom: from, occurredTo: to } : {};
45
+ }
46
+
47
+ // v0.8.0 A3:strictScope 硬过滤的 visibility 选项(store.list/count 同口径)。
48
+ // flag 关或会话 scope 未解析出(flag 关时解析器返回 null)→ 空对象不过滤;
49
+ // scope 为 {null,null}(匿名会话)时照传——store 侧 fail-closed 只放行未标注行。
50
+ function strictVisibility(config, scope) {
51
+ if (config?.strictScope !== true || !scope) return {};
52
+ return { visibility: { agentScope: scope.agent_scope, workspaceScope: scope.workspace_scope } };
53
+ }
54
+
55
+ // v0.8.0 A2:检索结果的 provenance 附加段——只在有标注时出现,未标注行渲染
56
+ // 与 A2 前逐字节一致。
57
+ const MEMORY_PROVENANCE = (m) => {
58
+ const parts = [];
59
+ if (m.occurred_at) parts.push(`occurred: ${m.occurred_at}`);
60
+ if (m.agent_scope || m.workspace_scope) {
61
+ parts.push(`scope: ${m.agent_scope ?? "global"} / ${m.workspace_scope ?? "global"}`);
62
+ }
63
+ if (m.sensitivity) parts.push(`sensitivity: ${m.sensitivity}`);
64
+ return parts.length ? ` | ${parts.join(" | ")}` : "";
65
+ };
66
+
33
67
  export function createTools(ctx, service, config, embedder) {
34
68
  const toolsRegistry = ctx.tools;
35
69
  let registeredTools = REGISTERED_TOOLS.get(toolsRegistry);
@@ -37,6 +71,10 @@ export function createTools(ctx, service, config, embedder) {
37
71
  registeredTools = new Set();
38
72
  REGISTERED_TOOLS.set(toolsRegistry, registeredTools);
39
73
  }
74
+ // v0.8.0 A1/A2(issue #17):会话 scope 解析(agentPreset + workspace 反查)。
75
+ // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
76
+ // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
77
+ const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
40
78
  const tools = [
41
79
  defineTool({
42
80
  name: "memory_save",
@@ -50,7 +88,9 @@ export function createTools(ctx, service, config, embedder) {
50
88
  content: { type: "string", required: true, description: "Memory body" },
51
89
  tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
52
90
  importance: { type: "integer", description: "1-5; >= threshold auto-injects into future sessions" },
53
- source: { type: "string", description: "Optional provenance" }
91
+ source: { type: "string", description: "Optional provenance" },
92
+ sensitivity: { type: "string", description: "Optional sensitivity label (free-form, e.g. personal). Same-title entries with different sensitivity stay separate instead of merging." },
93
+ occurred_at: { type: "string", description: "Optional ISO-8601 instant the remembered event happened (differs from write time). Invalid values are ignored." }
54
94
  },
55
95
  output: {
56
96
  schema: {
@@ -63,14 +103,21 @@ export function createTools(ctx, service, config, embedder) {
63
103
  },
64
104
  render: (_args, value) => TEXT_OUTPUT(`memory ${value.action}: ${value.id}`)
65
105
  },
66
- async execute(args) {
106
+ async execute(args, exec) {
107
+ // scope 标注:agent_scope/workspace_scope 由会话身份解析(不作为模型
108
+ // 参数),sensitivity/occurred_at 来自显式参数。解析绝不抛错、取不到
109
+ // 落 NULL;flag 关闭时 scope 为 null,一个字段都不标注。
110
+ const scope = resolveSessionScope(exec);
67
111
  const { action, memory } = service.saveWithDedupe({
68
112
  type: args.type,
69
113
  title: args.title,
70
114
  content: args.content,
71
115
  tags: args.tags ?? [],
72
116
  importance: args.importance ?? 3,
73
- source: args.source ?? "tool"
117
+ source: args.source ?? "tool",
118
+ ...(scope ? { agent_scope: scope.agent_scope, workspace_scope: scope.workspace_scope } : {}),
119
+ ...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
120
+ ...(args.occurred_at !== undefined ? { occurred_at: args.occurred_at } : {})
74
121
  });
75
122
  return { action, id: memory.id };
76
123
  }
@@ -79,12 +126,15 @@ export function createTools(ctx, service, config, embedder) {
79
126
  defineTool({
80
127
  name: "memory_search",
81
128
  description: "Search the cross-session memory store. Use when you need past context: how a problem was solved, user preferences, project decisions. Substring-matches title/content/tags, and augments results with semantic (vector) recall + optional rerank when an embeddings provider is configured. Returns matching entries with source and timestamps.",
129
+ // A2 检索接线:occurred 时间窗 + 会话 scope 加成(见 execute)。
82
130
  parameters: {
83
131
  query: { type: "string", required: true, description: "Search text; substring match over title/content/tags" },
84
132
  limit: { type: "integer", description: "Max results (default 20)" },
85
133
  mode: { type: "string", enum: ["auto", "keyword", "vector", "hybrid"], description: "auto (default) = keyword hits first + vector fill when enabled; keyword = text only; vector = semantic recall first (falls back to keyword); hybrid = vector leads, keyword fills remaining slots" },
86
134
  semantic: { type: "boolean", description: "Shorthand: enable semantic (vector) recall (same as mode=vector when true)" },
87
- rerank: { type: "boolean", description: "Run cross-encoder rerank over candidates when a local reranker is configured (default true)" }
135
+ rerank: { type: "boolean", description: "Run cross-encoder rerank over candidates when a local reranker is configured (default true)" },
136
+ occurred_from: { type: "string", description: "Optional ISO date/timestamp lower bound on when the remembered event happened (occurred_at, falling back to created_at). Date-only values are inclusive from that day's 00:00Z." },
137
+ occurred_to: { type: "string", description: "Optional ISO date/timestamp upper bound on occurred_at. Date-only values are inclusive through that day's 23:59:59.999Z." }
88
138
  },
89
139
  output: {
90
140
  schema: {
@@ -104,19 +154,26 @@ export function createTools(ctx, service, config, embedder) {
104
154
  .map((m, i) => {
105
155
  const preview = (m.content ?? "").replace(/\s+/g, " ").trim();
106
156
  const cut = preview.length > 200 ? `${preview.slice(0, 200)}…` : preview;
107
- return `[${i + 1}] ${m.title}\n ID: ${m.id} | type: ${m.type} | importance: ${m.importance} | updated: ${m.updated_at}\n ${cut}`;
157
+ return `[${i + 1}] ${m.title}\n ID: ${m.id} | type: ${m.type} | importance: ${m.importance} | updated: ${m.updated_at}${MEMORY_PROVENANCE(m)}\n ${cut}`;
108
158
  })
109
159
  .join("\n\n");
110
160
  return TEXT_OUTPUT(`Found ${items.length} memory entr${items.length === 1 ? "y" : "ies"}:\n\n${body}`);
111
161
  }
112
162
  },
113
- async execute(args) {
163
+ async execute(args, exec) {
114
164
  const limit = args.limit ?? 20;
115
165
  const mode = args.semantic === true && !args.mode ? "vector" : args.mode ?? "auto";
166
+ // v0.8.0 A2(issue #17):scope 检索加权 + occurred 时间窗的检索接线。
167
+ // flag 关闭或解析不出 scope 时为 null,检索行为与 A2 前一致。
168
+ const scope = resolveSessionScope(exec);
169
+ const occurredFrom = args.occurred_from ?? null;
170
+ const occurredTo = args.occurred_to ?? null;
116
171
  const rows = await service.searchMemories(args.query, {
117
172
  mode,
118
173
  topK: limit,
119
- useRerank: args.rerank !== false
174
+ useRerank: args.rerank !== false,
175
+ ...(scope ? { scope } : {}),
176
+ ...(occurredFrom !== null || occurredTo !== null ? { occurredFrom, occurredTo } : {})
120
177
  });
121
178
  return { items: service.toApiList(rows) };
122
179
  }
@@ -129,7 +186,9 @@ export function createTools(ctx, service, config, embedder) {
129
186
  type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "Filter by type; omit for all" },
130
187
  limit: { type: "integer", description: "Page size (default 50)" },
131
188
  offset: { type: "integer", description: "Page offset (default 0)" },
132
- include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" }
189
+ include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" },
190
+ occurred_from: { type: "string", description: "Optional ISO date/timestamp lower bound on when the remembered event happened (occurred_at, falling back to created_at). Date-only values are inclusive from that day's 00:00Z." },
191
+ occurred_to: { type: "string", description: "Optional ISO date/timestamp upper bound on occurred_at. Date-only values are inclusive through that day's 23:59:59.999Z." }
133
192
  },
134
193
  output: {
135
194
  schema: {
@@ -147,7 +206,7 @@ export function createTools(ctx, service, config, embedder) {
147
206
  const items = value.items ?? [];
148
207
  if (items.length === 0) return TEXT_OUTPUT(`0 memory entries (of ${value.total}).`);
149
208
  const body = items
150
- .map((m, i) => `[${i + 1}] ${m.title} (type=${m.type}, importance=${m.importance})\n ID: ${m.id} | updated: ${m.updated_at}`)
209
+ .map((m, i) => `[${i + 1}] ${m.title} (type=${m.type}, importance=${m.importance})\n ID: ${m.id} | updated: ${m.updated_at}${MEMORY_PROVENANCE(m)}`)
151
210
  .join("\n\n");
152
211
  return TEXT_OUTPUT(`${items.length} memory entries (of ${value.total}):\n\n${body}`);
153
212
  }
@@ -158,9 +217,13 @@ export function createTools(ctx, service, config, embedder) {
158
217
  type: args.type,
159
218
  limit: args.limit ?? 50,
160
219
  offset: args.offset ?? 0,
161
- includeArchived
220
+ includeArchived,
221
+ ...occurredWindow(args),
222
+ // A3:strict 开启时按会话 scope 硬过滤(宿主按 (args, runContext) 调用,
223
+ // 从 arguments 取第二参)。
224
+ ...strictVisibility(config, resolveSessionScope(arguments[1]))
162
225
  }));
163
- return { items: rows, total: service.count(args.type, { includeArchived }) };
226
+ return { items: rows, total: service.count(args.type, { includeArchived, ...occurredWindow(args), ...strictVisibility(config, resolveSessionScope(arguments[1])) }) };
164
227
  }
165
228
  }),
166
229
 
@@ -186,6 +249,10 @@ export function createTools(ctx, service, config, embedder) {
186
249
  tags: { type: "array", items: { type: "string" } },
187
250
  content: { type: "string", required: true },
188
251
  source: { type: "string" },
252
+ agent_scope: { type: "string" },
253
+ workspace_scope: { type: "string" },
254
+ sensitivity: { type: "string" },
255
+ occurred_at: { type: "string" },
189
256
  created_at: { type: "string" },
190
257
  updated_at: { type: "string" }
191
258
  }
@@ -194,12 +261,19 @@ export function createTools(ctx, service, config, embedder) {
194
261
  },
195
262
  render: (_args, value) => {
196
263
  const m = value.memory;
197
- return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}\n\n${m.content}`);
264
+ return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}${MEMORY_PROVENANCE(m)}\n\n${m.content}`);
198
265
  }
199
266
  },
200
267
  async execute(args) {
201
268
  const memory = service.getById(args.id);
202
269
  if (memory === undefined) throw new Error("memory not found");
270
+ // v0.8.0 A3:strictScope 下他 scope 的行按不存在处理(无存在性泄漏)。
271
+ // 宿主注册表按 (args, runContext) 两参调用处理器,从 arguments 取第二参。
272
+ const runContext = arguments[1];
273
+ const scope = resolveSessionScope(runContext);
274
+ if (config.strictScope === true && scope && !service.isVisibleInScope(memory, scope)) {
275
+ throw new Error("memory not found");
276
+ }
203
277
  return { memory: service.toApiList([memory])[0] };
204
278
  }
205
279
  }),
package/test/api.test.js CHANGED
@@ -649,11 +649,12 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
649
649
  // dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
650
650
  // recallFusion/signalTransparency、issue #109 新增的实体抽取路由
651
651
  // entityExtractionProvider/entityExtractionModel/entityExtractionReasoning、
652
- // issue #127 新增的 summarize 五键、issue #126 新增的 sleepActionSet,以及
653
- // issue #125 新增的候选集三键),未覆盖时取 bundle 配置的解析默认值;
652
+ // issue #127 新增的 summarize 五键、issue #126 新增的 sleepActionSet,
653
+ // 以及 issue #125 新增的候选集三键、issue #17(v0.8.0 A1/A3)新增的
654
+ // scopeEnabled/strictScope),未覆盖时取 bundle 配置的解析默认值;
654
655
  // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
655
- // 不编造给前端 → 51 - 2 = 49
656
- assert.equal(Object.keys(data.effective).length, 49);
656
+ // 不编造给前端 → 53 - 2 = 51
657
+ assert.equal(Object.keys(data.effective).length, 51);
657
658
  assert.equal(data.effective.dreamSkipInvalid, true);
658
659
  assert.equal(data.effective.allowCrossTypeMerge, false);
659
660
  assert.equal(data.effective.dreamMinIntervalMinutes, 0);
@@ -1068,6 +1069,91 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as
1068
1069
  assert.deepEqual(afterData.pendingMemoryIds, []);
1069
1070
  });
1070
1071
 
1072
+ // v0.8.0 冲突集中处理:GET /conflicts 队列联表 + POST /conflicts/resolve 人工确认。
1073
+ test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", async () => {
1074
+ const { routes, service } = setup();
1075
+ const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
1076
+ const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
1077
+ service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
1078
+
1079
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
1080
+ const res = new FakeRes();
1081
+ await route.handler(req("/api/dsh-mneme/conflicts"), res);
1082
+ assert.equal(res.statusCode, 200);
1083
+ const data = JSON.parse(res.body);
1084
+ assert.equal(data.items.length, 1);
1085
+ assert.equal(data.total, 1);
1086
+ assert.equal(data.items[0].reason, "主题偏好前后矛盾");
1087
+ // saveConflictPending 会按 id 字典序归一 A/B 侧位——断言集合而非位置。
1088
+ assert.deepEqual(
1089
+ [data.items[0].memory_a.title, data.items[0].memory_b.title].sort(),
1090
+ ["界面亮色切换", "界面暗色偏好"]
1091
+ );
1092
+ assert.equal(data.items[0].memory_a.archived, false);
1093
+ assert.equal(data.items[0].memory_b.archived, false);
1094
+ // 幽灵侧容忍缺失
1095
+ service.saveConflictPending({ memory_a: a.id, memory_b: "ghost-id", reason: "对方已删" });
1096
+ const res2 = new FakeRes();
1097
+ await route.handler(req("/api/dsh-mneme/conflicts"), res2);
1098
+ const data2 = JSON.parse(res2.body);
1099
+ assert.equal(data2.items.length, 2);
1100
+ assert.equal(data2.items.find((x) => x.reason === "对方已删").memory_b.missing, true);
1101
+ });
1102
+
1103
+ test("POST /api/dsh-mneme/conflicts/resolve stamps and applies the disposition", async () => {
1104
+ const { store, routes, service } = setup();
1105
+ const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
1106
+ const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
1107
+ const pending = service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
1108
+
1109
+ // store 会把 pair 按 id 字典序归一——从队列行反查「界面亮色切换」所在侧再选。
1110
+ const queueRoute = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
1111
+ const queueRes = new FakeRes();
1112
+ await queueRoute.handler(req("/api/dsh-mneme/conflicts"), queueRes);
1113
+ const queued = JSON.parse(queueRes.body).items.find((x) => x.id === pending.id);
1114
+ const keepSide = queued.memory_a.id === b.id ? "a" : "b";
1115
+ // loser 恒为 a(keeper 恒为「界面亮色切换」= b),与 A/B 侧位字母无关
1116
+ const loserId = a.id;
1117
+
1118
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts/resolve");
1119
+ // 保留「界面亮色切换」所在侧(apply 默认 true)→ 该侧保留,另一侧归档
1120
+ const res = new FakeRes();
1121
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: keepSide }), res);
1122
+ assert.equal(res.statusCode, 200);
1123
+ const data = JSON.parse(res.body);
1124
+ assert.equal(data.ok, true);
1125
+ assert.equal(data.conflict.resolved_winner, b.id);
1126
+ assert.equal(data.conflict.disposition.ok, true);
1127
+ assert.equal(store.getById(b.id).archived, false, "winner kept");
1128
+ assert.equal(store.getById(loserId).archived, true, "loser archived by apply");
1129
+
1130
+ // 处理过的 id 再 resolve → 幂等重盖(仍是 200;队列视图只列未解决行,
1131
+ // 正常 UI 不会再发这个请求,但接口层保持幂等)
1132
+ const res2 = new FakeRes();
1133
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: "a" }), res2);
1134
+ assert.equal(res2.statusCode, 200);
1135
+ assert.equal(JSON.parse(res2.body).ok, true);
1136
+
1137
+ // 未知 id → 404;缺 id → 400
1138
+ const res3 = new FakeRes();
1139
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: "nope", winner: "a" }), res3);
1140
+ assert.equal(res3.statusCode, 404);
1141
+ const res4 = new FakeRes();
1142
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", {}), res4);
1143
+ assert.equal(res4.statusCode, 400);
1144
+
1145
+ // apply:false 只盖章不处置
1146
+ const c = service.saveWithDedupe({ type: "decision", title: "丙", content: "内容丙" }).memory;
1147
+ const d = service.saveWithDedupe({ type: "decision", title: "丁", content: "内容丁" }).memory;
1148
+ const p2 = service.saveConflictPending({ memory_a: c.id, memory_b: d.id, reason: "r2" });
1149
+ const res5 = new FakeRes();
1150
+ await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: p2.id, apply: false }), res5);
1151
+ assert.equal(res5.statusCode, 200);
1152
+ assert.equal(JSON.parse(res5.body).conflict.disposition, null);
1153
+ assert.equal(store.getById(c.id).archived, false);
1154
+ assert.equal(store.getById(d.id).archived, false);
1155
+ });
1156
+
1071
1157
  test("parseHumanEdits is the pure core of readHumanEdits (CRLF tolerant)", () => {
1072
1158
  const digest = "0".repeat(64);
1073
1159
  const text = [
@@ -676,4 +676,62 @@ test("vector status card surfaces the configured/degraded split", () => {
676
676
  clientSource.includes('t("memory.status.vectorDegradedHint")'),
677
677
  "the degraded state must render its own caption (0 of {m} indexed)"
678
678
  );
679
+ });
680
+
681
+ // v0.8.0 A4(issue #17):作用域隔离的面板面——设置卡新组(scopeEnabled /
682
+ // strictScope 开关)+ 记忆条目的 scope provenance 展示。i18n 键必须中英双语
683
+ // 齐全;徽章/详情行沿用 heat 徽章的「字段缺省即不渲染」模式,前端不感知开关。
684
+ /**
685
+ * A4(issue #17)面板面回归:作用域隔离的设置卡分组、十个 i18n 键的中英双语
686
+ * 齐备(沿用 occurrences ≥ 2 模式),以及 ScopeBadge 组件与三处接线(cards
687
+ * 脚部 / timeline 行 / 详情抽屉 dmeta)的真实存在性——只做源码断言,不挂
688
+ * React 渲染环境,与文件内其他 clientSource 断言同一风格。
689
+ */
690
+ test("scope isolation ships panel switches and provenance surfaces (A4)", () => {
691
+ assert.ok(
692
+ /key: "group\.scope", items: \["scopeEnabled", "strictScope"\]/.test(clientSource),
693
+ "FEATURE_GROUPS must carry a dedicated scope group"
694
+ );
695
+ for (const key of [
696
+ "memory.features.group.scope",
697
+ "memory.features.scopeEnabled",
698
+ "memory.features.scopeEnabled.hint",
699
+ "memory.features.strictScope",
700
+ "memory.features.strictScope.hint",
701
+ "memory.explorer.scopeBadge",
702
+ "memory.explorer.detail.agentScope",
703
+ "memory.explorer.detail.workspaceScope",
704
+ "memory.explorer.detail.sensitivity",
705
+ "memory.explorer.detail.occurred"
706
+ ]) {
707
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
708
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
709
+ }
710
+ assert.ok(/const ScopeBadge = \(\{ m, t \}\)/.test(clientSource), "ScopeBadge component must exist");
711
+ assert.ok(clientSource.includes("h(ScopeBadge, { m, t })"), "ScopeBadge must be wired into browse views");
712
+ assert.ok(clientSource.includes('t("memory.explorer.detail.agentScope")'), "drawer must render agent scope row");
713
+ assert.ok(clientSource.includes('t("memory.explorer.detail.occurred")'), "drawer must render occurred row");
714
+ });
715
+
716
+ // v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
717
+ // 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
718
+ test("conflict queue ships a central review surface on the status tab", () => {
719
+ for (const key of [
720
+ "memory.status.conflictQueue.reason",
721
+ "memory.status.conflictQueue.sideA",
722
+ "memory.status.conflictQueue.sideB",
723
+ "memory.status.conflictQueue.keepA",
724
+ "memory.status.conflictQueue.keepB",
725
+ "memory.status.conflictQueue.markReviewed",
726
+ "memory.status.conflictQueue.applyHint",
727
+ "memory.status.conflictQueue.missing",
728
+ "memory.status.conflictQueue.refresh"
729
+ ]) {
730
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
731
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
732
+ }
733
+ assert.ok(/function ConflictsQueue\(\{ t \}\)/.test(clientSource), "ConflictsQueue component must exist");
734
+ assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts"'), "queue must fetch the conflicts endpoint");
735
+ assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
736
+ assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
679
737
  });
@@ -40,14 +40,51 @@ test("add registers live; remove unregisters live", () => {
40
40
  store.close();
41
41
  });
42
42
 
43
- test("handler returns the instruction as success text", async () => {
43
+ test("handler submits the instruction to the agent and returns a receipt", async () => {
44
44
  const { store, manager, registered } = setup();
45
45
  manager.add({ name: "fmt", description: "格式化", instruction: "请按项目的 lint 规则格式化当前文件" });
46
46
  const def = registered.get("fmt");
47
47
  assert.ok(def, "definition registered");
48
- const result = await def.handler({ agent: {}, rawInput: "", signal: null });
48
+ assert.equal(def.input.hint, "可选:追加给模型的补充说明");
49
+ const submitted = [];
50
+ const agent = { followup: (message) => submitted.push(message) };
51
+ const result = await def.handler({ agent, rawInput: "", signal: null });
49
52
  assert.equal(result.kind, "success");
50
- assert.equal(result.text, "请按项目的 lint 规则格式化当前文件");
53
+ assert.equal(result.text, "已提交指令:fmt");
54
+ assert.equal(submitted.length, 1);
55
+ assert.equal(submitted[0].role, "user");
56
+ assert.deepEqual(submitted[0].content, [{ type: "text", text: "请按项目的 lint 规则格式化当前文件" }]);
57
+ assert.deepEqual(submitted[0].source, { kind: "user" });
58
+ store.close();
59
+ });
60
+
61
+ test("rawInput suffix is appended for the model", async () => {
62
+ const { store, manager, registered } = setup();
63
+ manager.add({ name: "mneme", instruction: "以「小墨」的口吻自我介绍" });
64
+ const def = registered.get("mneme");
65
+ const submitted = [];
66
+ await def.handler({ agent: { followup: (m) => submitted.push(m) }, rawInput: " 顺便列出归档数量 " });
67
+ assert.equal(submitted[0].content[0].text, "以「小墨」的口吻自我介绍\n\n顺便列出归档数量");
68
+ store.close();
69
+ });
70
+
71
+ test("handler without a receiving agent falls back to surfacing the instruction", () => {
72
+ const { store, manager, registered } = setup();
73
+ manager.add({ name: "fmt", instruction: "legacy text" });
74
+ const def = registered.get("fmt");
75
+ const result = def.handler({ rawInput: "" });
76
+ assert.equal(result.kind, "success");
77
+ assert.equal(result.text, "legacy text");
78
+ store.close();
79
+ });
80
+
81
+ test("handler returns an error result when the agent rejects the submission", () => {
82
+ const { store, manager, registered } = setup();
83
+ manager.add({ name: "boom", instruction: "x" });
84
+ const def = registered.get("boom");
85
+ const result = def.handler({ agent: { followup: () => { throw new Error("nope"); } } });
86
+ assert.equal(result.kind, "error");
87
+ assert.match(result.text, /boom/);
51
88
  store.close();
52
89
  });
53
90