@modusensus/dsh-mneme 0.7.14 → 0.7.16
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/README.en.md +2 -2
- package/README.md +6 -4
- package/lib/api.js +338 -7
- package/lib/client.js +1238 -204
- package/lib/dream/sleep.js +17 -15
- package/lib/dream.js +94 -48
- package/lib/index.js +25 -2
- package/lib/mirror.js +93 -59
- package/lib/service.js +2 -0
- package/lib/settings.js +174 -0
- package/lib/store.js +77 -4
- package/package.json +12 -1
- package/src/api.js +338 -7
- package/src/dream/sleep.js +17 -15
- package/src/dream.js +94 -48
- package/src/index.js +25 -2
- package/src/mirror.js +93 -59
- package/src/service.js +2 -0
- package/src/settings.js +174 -0
- package/src/store.js +77 -4
- package/test/api.test.js +485 -2
- package/test/client.test.js +82 -52
- package/test/dream.test.js +1 -1
- package/test/helpers/peer-worker.mjs +17 -2
- package/test/llm-audit.test.js +34 -4
- package/test/peer-blockers.test.js +8 -2
- package/test/reasoning-effort.test.js +64 -0
- package/test/settings.test.js +136 -0
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
|
-
|
|
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
|
-
|
|
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/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.
|
|
4
|
+
"version": "0.7.16",
|
|
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"
|