@modusensus/dsh-mneme 0.7.13 → 0.7.15

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/index.js CHANGED
@@ -66,7 +66,30 @@ export const apply = (ctx, config) => {
66
66
  // semantic feature off and keeps the core loop (autoInject, autoSummarize,
67
67
  // hot memory, quality filter).
68
68
  const lightMode = rawCfg.lightMode === true || settings.getPanelMode() === "light";
69
- const cfg = applyLightModePreset({ ...rawCfg, lightMode });
69
+ // 功能开关合并顺序即优先级:用户显式开关(feature_flags kv,面板写入)>
70
+ // 轻量预设(applyLightModePreset 批量置关的重型能力)> bundle 配置。预设必须
71
+ // 先应用、用户开关后展开,否则 LIGHT_MODE_OFF 会把用户显式打开的开关再次
72
+ // 压掉。合并结果只作用于本次启动:面板改开关后与 panel_mode 一样在下次
73
+ // 启动生效。
74
+ // 嵌套对象开关按首个点号拆开(kv 里平铺存的 "memoryQualityFilter.enabled" →
75
+ // cfg.memoryQualityFilter.enabled),点号键不原样留在 cfg 顶层属性里。
76
+ const flags = settings.getFeatureFlags();
77
+ const flatFlags = {};
78
+ const nestedFlags = {};
79
+ for (const [key, value] of Object.entries(flags)) {
80
+ const dot = key.indexOf(".");
81
+ if (dot > 0) {
82
+ const objKey = key.slice(0, dot);
83
+ const subKey = key.slice(dot + 1);
84
+ nestedFlags[objKey] = { ...(nestedFlags[objKey] ?? {}), [subKey]: value };
85
+ } else {
86
+ flatFlags[key] = value;
87
+ }
88
+ }
89
+ const cfg = { ...applyLightModePreset({ ...rawCfg, lightMode }), ...flatFlags };
90
+ for (const [objKey, sub] of Object.entries(nestedFlags)) {
91
+ cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
92
+ }
70
93
 
71
94
  const mirror = createMirror(memoryDir);
72
95
  const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
@@ -340,7 +363,7 @@ export const apply = (ctx, config) => {
340
363
  add: () => { throw new Error("commands unavailable"); },
341
364
  remove: () => false,
342
365
  list: () => []
343
- }, embedder, { vectorIndex, reranker }, cfg.apiToken);
366
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg);
344
367
  disposers.push(api.dispose);
345
368
  }
346
369
 
package/lib/mirror.js CHANGED
@@ -47,6 +47,92 @@ function renderMemory(m) {
47
47
  return lines.join("\n");
48
48
  }
49
49
 
50
+ /**
51
+ * Render one type's memories into exactly the mirror-file text (header +
52
+ * per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
53
+ * the /export endpoint returns the same text, so an exported markdown is
54
+ * byte-compatible with a mirror file and can be fed straight back through
55
+ * parseHumanEdits → mergeHumanEdits. Unknown type → undefined.
56
+ */
57
+ export function renderMirrorText(type, memories) {
58
+ const name = TYPE_FILE[type];
59
+ if (!name) return undefined;
60
+ const items = (memories ?? [])
61
+ .slice()
62
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
63
+ const header = `# ${name} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
64
+ const body = items.map(renderMemory).join("\n");
65
+ return header + body;
66
+ }
67
+
68
+ /**
69
+ * Parse mirror text back into {id, title, content} entries for human edits.
70
+ * Pure text-in/edits-out core: readHumanEdits feeds it mirror file contents
71
+ * and the /import endpoint feeds it user-pasted markdown, so both paths share
72
+ * one parsing implementation (行为一致是硬约束——import 必须能吃回 export 与
73
+ * 磁盘镜像)。Entries are anchored on "- **ID**: `...`" lines that are followed
74
+ * by the "- **类型**:" metadata line (structural entry head): each entry's
75
+ * block spans from its ID line up to the next ID line (or end of text). The
76
+ * block head (the ID line plus the generated metadata run) and the trailing
77
+ * structural "---" separator are stripped; everything in between is the entry
78
+ * body, so user content containing "---", metadata-like lines, or even a
79
+ * machine-format "- **ID**: `x`" line is preserved. The title is the "## "
80
+ * heading preceding the ID line.
81
+ */
82
+ export function parseHumanEdits(text) {
83
+ // CRLF 归一化(readHumanEdits 原有的读取侧处理移入纯函数,Windows 手工编辑
84
+ // 的文件与导入文本都能正确解析)。
85
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n");
86
+ const edits = [];
87
+ // Anchor on the ID line only when it is a structural entry head: the
88
+ // machine-rendered ID line is always followed by the "- **类型**:" line.
89
+ // A body line like "- **ID**: `x`" is not, so it never splits the block
90
+ // or produces a ghost entry.
91
+ const anchors = [...normalized.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
92
+ let prevEnd = 0;
93
+ for (let i = 0; i < anchors.length; i++) {
94
+ const anchor = anchors[i];
95
+ const blockStart = anchor.index;
96
+ const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : normalized.length;
97
+
98
+ // Title: last "## " heading before this ID line (file header region /
99
+ // previous block tail). Body headings of earlier entries come before
100
+ // the structural "---" + "## " of this entry, so the last match wins.
101
+ const titleMatches = [...normalized.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
102
+ const titleMatch = titleMatches[titleMatches.length - 1];
103
+
104
+ // Body: the ID line and the generated metadata run are structural head;
105
+ // everything after them up to the trailing "---" separator is the body.
106
+ let body = normalized
107
+ .slice(blockStart, blockEnd)
108
+ .replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
109
+ .replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "")
110
+ .replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
111
+ const separators = [...body.matchAll(/^---\s*$/gm)];
112
+ const lastSep = separators[separators.length - 1];
113
+ if (lastSep) body = body.slice(0, lastSep.index);
114
+ body = body.trim();
115
+
116
+ // The machine-written "更新时间" line records the store's updated_at at
117
+ // render time — the version token for detecting a concurrent store write
118
+ // during a three-way merge of human edits (see service.syncMirror).
119
+ const block = normalized.slice(blockStart, blockEnd);
120
+ const updatedMatch = block.match(/- \*\*更新时间\*\*: ([^\n]+)/);
121
+ const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
122
+ edits.push({
123
+ id: anchor[1],
124
+ title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
125
+ content: body,
126
+ updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
127
+ digest: digestMatch ? digestMatch[1] : undefined
128
+ });
129
+
130
+ const lineEnd = normalized.indexOf("\n", blockStart);
131
+ prevEnd = lineEnd === -1 ? normalized.length : lineEnd + 1;
132
+ }
133
+ return edits;
134
+ }
135
+
50
136
  export function createMirror(dir) {
51
137
  mkdirSync(dir, { recursive: true });
52
138
 
@@ -56,15 +142,9 @@ export function createMirror(dir) {
56
142
  }
57
143
 
58
144
  /**
59
- * Parse a mirror file back into {id, title, content} entries for human edits.
60
- * Entries are anchored on "- **ID**: `...`" lines that are followed by the
61
- * "- **类型**:" metadata line (structural entry head): each entry's block
62
- * spans from its ID line up to the next ID line (or end of file). The block
63
- * head (the ID line plus the generated metadata run) and the trailing
64
- * structural "---" separator are stripped; everything in between is the entry
65
- * body, so user content containing "---", metadata-like lines, or even a
66
- * machine-format "- **ID**: `x`" line is preserved. The title is the "## "
67
- * heading preceding the ID line.
145
+ * Read the mirror files and parse them back into human edits. The pure
146
+ * parsing logic lives in the exported parseHumanEdits (shared with /import);
147
+ * this wrapper only owns the "read file text" side.
68
148
  */
69
149
  function readHumanEdits(type = undefined) {
70
150
  const types = type ? [type] : Object.keys(TYPE_FILE);
@@ -72,53 +152,7 @@ export function createMirror(dir) {
72
152
  for (const t of types) {
73
153
  const file = filePath(t);
74
154
  if (!file || !existsSync(file)) continue;
75
- const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
76
- // Anchor on the ID line only when it is a structural entry head: the
77
- // machine-rendered ID line is always followed by the "- **类型**:" line.
78
- // A body line like "- **ID**: `x`" is not, so it never splits the block
79
- // or produces a ghost entry.
80
- const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
81
- let prevEnd = 0;
82
- for (let i = 0; i < anchors.length; i++) {
83
- const anchor = anchors[i];
84
- const blockStart = anchor.index;
85
- const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
86
-
87
- // Title: last "## " heading before this ID line (file header region /
88
- // previous block tail). Body headings of earlier entries come before
89
- // the structural "---" + "## " of this entry, so the last match wins.
90
- const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
91
- const titleMatch = titleMatches[titleMatches.length - 1];
92
-
93
- // Body: the ID line and the generated metadata run are structural head;
94
- // everything after them up to the trailing "---" separator is the body.
95
- let body = text
96
- .slice(blockStart, blockEnd)
97
- .replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
98
- .replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "")
99
- .replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
100
- const separators = [...body.matchAll(/^---\s*$/gm)];
101
- const lastSep = separators[separators.length - 1];
102
- if (lastSep) body = body.slice(0, lastSep.index);
103
- body = body.trim();
104
-
105
- // The machine-written "更新时间" line records the store's updated_at at
106
- // render time — the version token for detecting a concurrent store write
107
- // during a three-way merge of human edits (see service.syncMirror).
108
- const block = text.slice(blockStart, blockEnd);
109
- const updatedMatch = block.match(/- \*\*更新时间\*\*: ([^\n]+)/);
110
- const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
111
- edits.push({
112
- id: anchor[1],
113
- title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
114
- content: body,
115
- updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
116
- digest: digestMatch ? digestMatch[1] : undefined
117
- });
118
-
119
- const lineEnd = text.indexOf("\n", blockStart);
120
- prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
121
- }
155
+ edits.push(...parseHumanEdits(readFileSync(file, "utf8")));
122
156
  }
123
157
  return edits;
124
158
  }
@@ -145,9 +179,9 @@ export function createMirror(dir) {
145
179
  // memories do not "resurrect" via readHumanEdits
146
180
  rmSync(file, { force: true });
147
181
  } else {
148
- const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
149
- const body = items.map(renderMemory).join("\n");
150
- writeFileSync(file, header + body, "utf8");
182
+ // 渲染走 renderMirrorText(与 /export 共用同一条渲染路径),磁盘镜像
183
+ // 与导出文本永远同构。
184
+ writeFileSync(file, renderMirrorText(type, items), "utf8");
151
185
  }
152
186
  results[type] = { ok: true };
153
187
  } catch (error) {
package/lib/service.js CHANGED
@@ -1511,6 +1511,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1511
1511
  findEntityByName: (n) => store.findEntityByName(n),
1512
1512
  findEntityById: (id) => store.findEntityById(id),
1513
1513
  getAttrsByMemory: (id) => store.getAttrsByMemory(id),
1514
+ // 记忆详情侧栏:一条记忆关联到的实体(entity_attrs.memory_id 反查,纯读)。
1515
+ entitiesForMemory: (id) => store.entitiesForMemory(id),
1514
1516
  getCurrentAttrs: (id) => store.getCurrentAttrs(id),
1515
1517
  migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
1516
1518
  };
package/lib/settings.js CHANGED
@@ -31,6 +31,155 @@ function parseList(raw) {
31
31
  }
32
32
  }
33
33
 
34
+ // --- feature flags(功能开关)白名单 -----------------------------------------
35
+ // 面板可逐项开关的后端能力。设计约束:
36
+ // 1. 键名与类型必须和 config.js schema 同名同型,这份白名单是唯一校验源
37
+ // (api.js 复用它计算 effective),schema 增删能力键时要同步改这里。
38
+ // 2. 持久化(kv "feature_flags")只落白名单键;读到未知键、类型损坏或越界的
39
+ // 值一律丢弃而不是报错——kv 会残留旧版本写入的键,读路径必须向前兼容。
40
+ // 3. 写入是逐键校验的合并写:未知键/类型/范围不符抛 TypeError(消息含键名,
41
+ // 供 API 透传给前端定位),校验不通过不落库,坏值永远进不了 kv。
42
+ const FEATURE_FLAG_BOOLEANS = [
43
+ "autoInject",
44
+ "autoSummarize",
45
+ "hotMemoryEnabled",
46
+ "entityExtractionEnabled",
47
+ "codingRetrospect",
48
+ "autoDream",
49
+ "sleepModeEnabled",
50
+ "hybridInject",
51
+ "selectiveInjectEnabled",
52
+ "searchSemanticDedup",
53
+ "rerankEnabled",
54
+ "adaptiveThresholdEnabled",
55
+ "reflectionUpdateEnabled",
56
+ "reflectionFailureTracking",
57
+ "bm25SearchEnabled",
58
+ "conflictFreezeEnabled",
59
+ "trustEpistemicWeighting",
60
+ // 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
61
+ // 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
62
+ // 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
63
+ "memoryQualityFilter.enabled",
64
+ "llmAudit.enabled"
65
+ ];
66
+ // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
67
+ const FEATURE_FLAG_INT_RANGES = {
68
+ distillRateLimitIntervalMs: [0, 60000],
69
+ distillRateLimitRetries: [0, 10],
70
+ distillRateLimitBaseDelayMs: [100, 60000],
71
+ distillMaxChars: [1000, 200000],
72
+ codingBoostFactor: [1, 5]
73
+ };
74
+ // 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
75
+ // 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
76
+ const FEATURE_FLAG_STRINGS = [
77
+ "dreamProvider",
78
+ "dreamModel",
79
+ "localEmbedModel",
80
+ "ollamaModel"
81
+ ];
82
+ // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议,
83
+ // 拒绝其余协议——这是 SSRF 防线的一部分)。
84
+ const FEATURE_FLAG_URLS = ["ollamaBaseUrl"];
85
+ // 枚举开关(与 config.js 的 z.union(z.const(...)) 对齐):仅允许列出的值。
86
+ const FEATURE_FLAG_ENUMS = {
87
+ embedProvider: ["openai", "local", "ollama"]
88
+ };
89
+ const FEATURE_FLAG_STRING_MAX = 200;
90
+
91
+ // 供 api.js 复用同一份白名单(effective 只在白名单键上计算)。
92
+ export const FEATURE_FLAG_SPEC = {
93
+ booleans: FEATURE_FLAG_BOOLEANS,
94
+ ints: FEATURE_FLAG_INT_RANGES,
95
+ strings: FEATURE_FLAG_STRINGS,
96
+ urls: FEATURE_FLAG_URLS,
97
+ enums: FEATURE_FLAG_ENUMS
98
+ };
99
+
100
+ /** ollamaBaseUrl 的协议白名单:只接受 http/https(SSRF 防线的一部分)。 */
101
+ function isHttpUrl(value) {
102
+ try {
103
+ const protocol = new URL(value).protocol;
104
+ return protocol === "http:" || protocol === "https:";
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /** 校验单个开关值;不合法抛 TypeError(消息含键名)。 */
111
+ function validateFlag(key, value) {
112
+ if (FEATURE_FLAG_BOOLEANS.includes(key)) {
113
+ if (typeof value !== "boolean") {
114
+ throw new TypeError(`feature flag "${key}" must be a boolean`);
115
+ }
116
+ return value;
117
+ }
118
+ const range = FEATURE_FLAG_INT_RANGES[key];
119
+ if (range) {
120
+ const [min, max] = range;
121
+ if (!Number.isInteger(value) || value < min || value > max) {
122
+ throw new TypeError(`feature flag "${key}" must be an integer in [${min}, ${max}]`);
123
+ }
124
+ return value;
125
+ }
126
+ if (FEATURE_FLAG_STRINGS.includes(key)) {
127
+ if (typeof value !== "string") {
128
+ throw new TypeError(`feature flag "${key}" must be a string`);
129
+ }
130
+ const trimmed = value.trim();
131
+ if (trimmed.length > FEATURE_FLAG_STRING_MAX) {
132
+ throw new TypeError(`feature flag "${key}" must be at most ${FEATURE_FLAG_STRING_MAX} characters`);
133
+ }
134
+ return trimmed; // 空串合法 = 跟随默认
135
+ }
136
+ if (FEATURE_FLAG_URLS.includes(key)) {
137
+ if (typeof value !== "string") {
138
+ throw new TypeError(`feature flag "${key}" must be a string`);
139
+ }
140
+ const trimmed = value.trim();
141
+ if (trimmed && !isHttpUrl(trimmed)) {
142
+ throw new TypeError(`feature flag "${key}" must be empty or a valid http(s) URL`);
143
+ }
144
+ return trimmed; // 空串合法 = 跟随默认
145
+ }
146
+ const allowed = FEATURE_FLAG_ENUMS[key];
147
+ if (allowed) {
148
+ if (typeof value !== "string" || !allowed.includes(value)) {
149
+ throw new TypeError(`feature flag "${key}" must be one of: ${allowed.join(", ")}`);
150
+ }
151
+ return value;
152
+ }
153
+ throw new TypeError(`unknown feature flag "${key}"`);
154
+ }
155
+
156
+ /** 清洗已存的 feature_flags 对象:只保留白名单键,类型/范围损坏的键丢弃。 */
157
+ function sanitizeFlags(raw) {
158
+ const out = {};
159
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return out;
160
+ for (const key of FEATURE_FLAG_BOOLEANS) {
161
+ if (typeof raw[key] === "boolean") out[key] = raw[key];
162
+ }
163
+ for (const [key, [min, max]] of Object.entries(FEATURE_FLAG_INT_RANGES)) {
164
+ if (Number.isInteger(raw[key]) && raw[key] >= min && raw[key] <= max) out[key] = raw[key];
165
+ }
166
+ for (const key of FEATURE_FLAG_STRINGS) {
167
+ if (typeof raw[key] === "string" && raw[key].trim().length <= FEATURE_FLAG_STRING_MAX) {
168
+ out[key] = raw[key].trim();
169
+ }
170
+ }
171
+ for (const key of FEATURE_FLAG_URLS) {
172
+ if (typeof raw[key] === "string") {
173
+ const trimmed = raw[key].trim();
174
+ if (!trimmed || isHttpUrl(trimmed)) out[key] = trimmed;
175
+ }
176
+ }
177
+ for (const [key, allowed] of Object.entries(FEATURE_FLAG_ENUMS)) {
178
+ if (allowed.includes(raw[key])) out[key] = raw[key];
179
+ }
180
+ return out;
181
+ }
182
+
34
183
  export function createSettings(db) {
35
184
  db.exec(SCHEMA);
36
185
 
@@ -177,6 +326,31 @@ export function createSettings(db) {
177
326
  },
178
327
  setPanelMode(mode) {
179
328
  setSetting("panel_mode", mode === "light" ? "light" : "standard");
329
+ },
330
+
331
+ /**
332
+ * Feature flags(kv "feature_flags"):面板对后端能力的显式覆盖。读取只
333
+ * 返回白名单内的合法键(默认 {}),写入是逐键校验后的合并持久化。
334
+ */
335
+ getFeatureFlags() {
336
+ const raw = getSetting("feature_flags");
337
+ if (!raw) return {};
338
+ try {
339
+ return sanitizeFlags(JSON.parse(raw));
340
+ } catch {
341
+ return {};
342
+ }
343
+ },
344
+ setFeatureFlags(patch) {
345
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
346
+ throw new TypeError("feature flags patch must be a plain object");
347
+ }
348
+ const merged = this.getFeatureFlags();
349
+ for (const [key, value] of Object.entries(patch)) {
350
+ merged[key] = validateFlag(key, value);
351
+ }
352
+ setSetting("feature_flags", JSON.stringify(merged));
353
+ return merged;
180
354
  }
181
355
  };
182
356
  }
package/lib/store.js CHANGED
@@ -303,6 +303,25 @@ function escapeLike(q) {
303
303
  return q.replace(/[\\%_]/g, (c) => `\\${c}`);
304
304
  }
305
305
 
306
+ // 日期过滤参数归一化(list/count 共用,保证分页 total 与行同过滤):接受 ISO
307
+ // 日期("2026-09-01")或完整时间戳,返回闭区间的 UTC ISO 边界。date-only 的
308
+ // updatedFrom 按当天 00:00:00.000Z 起、updatedTo 按当天 23:59:59.999Z 收;非
309
+ // 法值一律返回 undefined → 不进 WHERE(忽略而非报错:面板传坏参数时宁可放宽
310
+ // 过滤也不要白屏)。updated_at 列是 toISOString 产生的 UTC "Z" 字符串,字典
311
+ // 序与时间序一致,SQL 里可直接比较。
312
+ function updatedAtBounds(updatedFrom, updatedTo) {
313
+ const norm = (raw, endOfDay) => {
314
+ if (typeof raw !== "string" || !raw.trim()) return undefined;
315
+ const s = raw.trim();
316
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(s);
317
+ const ms = Date.parse(dateOnly ? `${s}T00:00:00.000Z` : s);
318
+ if (Number.isNaN(ms)) return undefined;
319
+ if (dateOnly && endOfDay) return `${s}T23:59:59.999Z`;
320
+ return new Date(ms).toISOString();
321
+ };
322
+ return { from: norm(updatedFrom, false), to: norm(updatedTo, true) };
323
+ }
324
+
306
325
  function parseTags(raw) {
307
326
  try {
308
327
  const arr = JSON.parse(raw);
@@ -618,7 +637,7 @@ export function createStore(path) {
618
637
  return ts;
619
638
  }
620
639
 
621
- function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false } = {}) {
640
+ function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, updatedFrom = null, updatedTo = null } = {}) {
622
641
  const clauses = [];
623
642
  const params = [];
624
643
  if (type !== undefined) {
@@ -634,10 +653,24 @@ export function createStore(path) {
634
653
  clauses.push("source = ?");
635
654
  params.push(source);
636
655
  }
656
+ // updated_at 闭区间:与 list() 共用 updatedAtBounds 归一化,非法值被忽略
657
+ // (不进 WHERE),total 才能和行保持同过滤。
658
+ const bounds = updatedAtBounds(updatedFrom, updatedTo);
659
+ if (bounds.from) {
660
+ clauses.push("updated_at >= ?");
661
+ params.push(bounds.from);
662
+ }
663
+ if (bounds.to) {
664
+ clauses.push("updated_at <= ?");
665
+ params.push(bounds.to);
666
+ }
637
667
  if (!includeForgotten) {
638
668
  clauses.push("forgotten = 0");
639
669
  }
640
- if (!includeArchived) {
670
+ // 与 list() 同过滤:total 才能和归档列表的行保持一致。
671
+ if (onlyArchived) {
672
+ clauses.push("archived = 1");
673
+ } else if (!includeArchived) {
641
674
  clauses.push("archived = 0");
642
675
  }
643
676
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
@@ -891,7 +924,7 @@ export function createStore(path) {
891
924
  return rows.map(toRow);
892
925
  }
893
926
 
894
- function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, minImportance = null, source = null } = {}) {
927
+ function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
895
928
  const clauses = [];
896
929
  const params = [];
897
930
  if (type) {
@@ -908,10 +941,25 @@ export function createStore(path) {
908
941
  clauses.push("source = ?");
909
942
  params.push(source);
910
943
  }
944
+ // Optional updated_at closed range (date-only "to" is normalized to the
945
+ // end of that day). Same helper as count() so total matches the rows.
946
+ const bounds = updatedAtBounds(updatedFrom, updatedTo);
947
+ if (bounds.from) {
948
+ clauses.push("updated_at >= ?");
949
+ params.push(bounds.from);
950
+ }
951
+ if (bounds.to) {
952
+ clauses.push("updated_at <= ?");
953
+ params.push(bounds.to);
954
+ }
911
955
  if (!includeForgotten) {
912
956
  clauses.push("forgotten = 0");
913
957
  }
914
- if (!includeArchived) {
958
+ // onlyArchived:只看归档(状态页的归档列表用);与 includeArchived(含
959
+ // 归档混看)互斥,同时给时归档视图优先。
960
+ if (onlyArchived) {
961
+ clauses.push("archived = 1");
962
+ } else if (!includeArchived) {
915
963
  clauses.push("archived = 0");
916
964
  }
917
965
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
@@ -1598,6 +1646,30 @@ export function createStore(path) {
1598
1646
  ).all(memoryId).map(toAttr);
1599
1647
  }
1600
1648
 
1649
+ /**
1650
+ * 一条记忆关联到的实体(记忆详情侧栏用):entity_attrs.memory_id 反查实体,
1651
+ * 一条 JOIN 完成。同一记忆对同一实体的多次提及(多条 attr 行)按 name 去重,
1652
+ * 每个实体只出现一次,按提及次数降序。只取 name/type——attr 详情走
1653
+ * entity-attrs 端点。无关联(或记忆不存在)返回空数组。
1654
+ */
1655
+ function entitiesForMemory(memoryId) {
1656
+ const rows = db.prepare(
1657
+ `SELECT e.id, e.name, e.type, e.mention_count, e.last_seen
1658
+ FROM entity_attrs ea JOIN entities e ON e.id = ea.entity_id
1659
+ WHERE ea.memory_id = ?
1660
+ GROUP BY e.id
1661
+ ORDER BY e.mention_count DESC, e.last_seen DESC, e.name ASC`
1662
+ ).all(memoryId ?? "");
1663
+ const seen = new Set();
1664
+ const out = [];
1665
+ for (const row of rows) {
1666
+ if (seen.has(row.name)) continue;
1667
+ seen.add(row.name);
1668
+ out.push({ name: row.name, type: row.type ?? null });
1669
+ }
1670
+ return out;
1671
+ }
1672
+
1601
1673
  /**
1602
1674
  * Memories carrying a currently-valid attr matching key=value (deduped).
1603
1675
  * When value is empty/undefined, the attr_value filter is dropped and every
@@ -1941,6 +2013,7 @@ export function createStore(path) {
1941
2013
  getCurrentAttrs,
1942
2014
  getAttrHistory,
1943
2015
  getAttrsByMemory,
2016
+ entitiesForMemory,
1944
2017
  findMemoriesByAttr,
1945
2018
  saveRelation,
1946
2019
  migrateAttrsToMemory,
package/lib/summarize.js CHANGED
@@ -8,7 +8,7 @@ const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内
8
8
  // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
9
  // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
10
10
  // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
11
- const CODING_SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手思考/回答、工具调用与结果),提炼值得跨会话记住的原子记忆。
11
+ const CODING_SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手回答、工具调用与结果),提炼值得跨会话记住的原子记忆。
12
12
  原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
13
13
  只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history|rejected_solution|pitfall|constraint","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
14
14
  若对话涉及编码/调试,可额外提取编码类记忆:
@@ -91,18 +91,22 @@ function toProtocolChunk(chunk) {
91
91
  // check below.
92
92
  //
93
93
  // codingRetrospect: the distill context is the FULL turn transcript —
94
- // user prompts plus assistant thinking/replies, tool calls + results and code
94
+ // user prompts plus assistant public replies, tool calls + results and code
95
95
  // dispatch output — so the summarizer can see tool errors and extract pitfall
96
96
  // root causes, not just what the user typed. The same filtering stays: only
97
97
  // source.kind === "user" prompts enter (plugin/machine content is excluded).
98
98
  // The result is a single text transcript passed to the LLM as one user message
99
99
  // (SUMMARY_PROMPT already says "根据下面的会话内容").
100
+ //
101
+ // Privacy: assistant `reasoning` (private thought) blocks are deliberately NOT
102
+ // collected — distilled memories must never sink private reasoning chains.
103
+ // Only public text blocks (type "text") reach the summarizer.
100
104
  function collectMessages(session, maxChars = 8000) {
101
105
  // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events
102
106
  const events = session.snapshotEvents?.() ?? session.events ?? [];
103
107
  const lines = [];
104
108
  // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
105
- // 直接传字符串数组)。text 之外按需抽 thinking/reasoning 块。
109
+ // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
106
110
  const textOf = (content) => {
107
111
  if (typeof content === "string") return content;
108
112
  if (!Array.isArray(content)) return "";
@@ -127,11 +131,7 @@ function collectMessages(session, maxChars = 8000) {
127
131
  const blocks = Array.isArray(msg?.content) ? msg.content : [];
128
132
  const text = textOf(blocks);
129
133
  if (text.trim()) lines.push(`助手:${text}`);
130
- const thinking = blocks
131
- .map((b) => (typeof b === "string" ? "" : (b && b.type === "reasoning" && typeof b.text === "string" ? b.text : "")))
132
- .filter((s) => s)
133
- .join("\n");
134
- if (thinking.trim()) lines.push(`助手思考:${thinking}`);
134
+ // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
135
135
  break;
136
136
  }
137
137
  case "tool/call": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.7.13",
4
+ "version": "0.7.15",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -73,6 +73,17 @@
73
73
  "e2e": "node scripts/e2e-dsh.js",
74
74
  "stress": "node scripts/stress-dsh.js"
75
75
  },
76
+ "c8": {
77
+ "reporter": [
78
+ "text",
79
+ "lcov"
80
+ ],
81
+ "exclude": [
82
+ "lib/**",
83
+ "test/**",
84
+ "**/*.test.js"
85
+ ]
86
+ },
76
87
  "dependencies": {
77
88
  "@huggingface/transformers": "^4.2.0",
78
89
  "morphicons": "^1.7.1"
@@ -0,0 +1,42 @@
1
+ // 发布前校验 src/ 与 lib/ 一致性。
2
+ //
3
+ // npm 包实际加载 lib/(package main → lib/index.js),而 root 层 `npm publish`
4
+ // 不触发 dsh-mneme/ 的 prepack → sync 同步。历史教训:PR #60 只改了 src 忘了同步
5
+ // lib,v0.7.8 发出去的 npm 包跑的还是旧代码(issue #65)。此脚本让这类漂移在发布时
6
+ // 直接 fail,而不是带着旧产物上线。
7
+ //
8
+ // 用法:node scripts/check-sync.js (root package.json 的 prepack 钩子自动调用)
9
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { join, relative } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const root = join(fileURLToPath(new URL("..", import.meta.url))); // dsh-mneme/
14
+ const srcDir = join(root, "src");
15
+ const libDir = join(root, "lib");
16
+
17
+ function walk(dir) {
18
+ const out = [];
19
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
20
+ const full = join(dir, entry.name);
21
+ if (entry.isDirectory()) out.push(...walk(full));
22
+ else if (entry.isFile()) out.push(full);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ const bad = [];
28
+ for (const file of walk(srcDir)) {
29
+ const rel = relative(srcDir, file);
30
+ const dest = join(libDir, rel);
31
+ if (!existsSync(dest)) {
32
+ bad.push(`missing lib/${rel}`);
33
+ } else if (!readFileSync(file).equals(readFileSync(dest))) {
34
+ bad.push(`differ lib/${rel}`);
35
+ }
36
+ }
37
+ if (bad.length) {
38
+ console.error(`✗ src/ 与 lib/ 不一致(${bad.length} 处)——npm 包实际加载 lib/,请先 npm run sync 并提交:`);
39
+ for (const line of bad) console.error(` ${line}`);
40
+ process.exit(1);
41
+ }
42
+ console.log(`✓ src/ 与 lib/ 一致(${walk(srcDir).length} 个文件)`);