@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/src/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/src/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/src/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": {