@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/README.en.md +2 -2
- package/README.md +7 -5
- package/lib/api.js +338 -7
- package/lib/client.js +1238 -204
- 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/lib/summarize.js +8 -8
- package/package.json +12 -1
- package/scripts/check-sync.js +42 -0
- package/src/api.js +338 -7
- 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/src/summarize.js +8 -8
- package/test/api.test.js +485 -2
- package/test/client.test.js +82 -52
- package/test/helpers/peer-worker.mjs +17 -2
- package/test/peer-blockers.test.js +9 -1
- package/test/settings.test.js +136 -0
- package/test/summarize.test.js +28 -0
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
|
-
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
writeFileSync(file,
|
|
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
|
-
|
|
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/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
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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} 个文件)`);
|