@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/src/api.js
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
4
|
+
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
5
|
+
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
8
|
+
function sendJson(res, status, payload, headers = {}) {
|
|
9
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
|
|
6
10
|
res.end(JSON.stringify(payload));
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
// 附件下载响应(导出端点):Content-Type 与文件名(含日期后缀)由调用方给定。
|
|
14
|
+
function sendAttachment(res, status, contentType, filename, body) {
|
|
15
|
+
res.writeHead(status, {
|
|
16
|
+
"Content-Type": contentType,
|
|
17
|
+
"Content-Disposition": `attachment; filename="${filename}"`
|
|
18
|
+
});
|
|
19
|
+
res.end(body);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 导出 JSON 的 version 字段:读插件根的 package.json(src/ 与 lib/ 都在根下
|
|
23
|
+
// 一层,相对 import.meta.url 解析一致)。读取失败(打包/受限环境)降级为
|
|
24
|
+
// "unknown",导出本身仍然可用。
|
|
25
|
+
const PACKAGE_VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "unknown";
|
|
28
|
+
} catch {
|
|
29
|
+
return "unknown";
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
|
|
9
33
|
// Defaults for the standalone external API — keep in step with the schema
|
|
10
34
|
// defaults in config.js (externalApiPort / externalApiHost).
|
|
11
35
|
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
@@ -70,9 +94,43 @@ function parseBody(text) {
|
|
|
70
94
|
}
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "") {
|
|
97
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) {
|
|
74
98
|
const disposers = [];
|
|
75
99
|
|
|
100
|
+
// feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
|
|
101
|
+
// effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
|
|
102
|
+
// (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
|
|
103
|
+
const flagKeys = [
|
|
104
|
+
...FEATURE_FLAG_SPEC.booleans,
|
|
105
|
+
...Object.keys(FEATURE_FLAG_SPEC.ints),
|
|
106
|
+
...FEATURE_FLAG_SPEC.strings,
|
|
107
|
+
...FEATURE_FLAG_SPEC.urls,
|
|
108
|
+
...Object.keys(FEATURE_FLAG_SPEC.enums)
|
|
109
|
+
];
|
|
110
|
+
// 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
|
|
111
|
+
// 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
|
|
112
|
+
const NESTED_FLAG_PATHS = {
|
|
113
|
+
"memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
|
|
114
|
+
"llmAudit.enabled": ["llmAudit", "enabled"]
|
|
115
|
+
};
|
|
116
|
+
function configFlagValue(key) {
|
|
117
|
+
const path = NESTED_FLAG_PATHS[key];
|
|
118
|
+
if (path) return config?.[path[0]]?.[path[1]];
|
|
119
|
+
return config?.[key];
|
|
120
|
+
}
|
|
121
|
+
function featureSnapshot() {
|
|
122
|
+
const overrides = settings.getFeatureFlags();
|
|
123
|
+
const effective = {};
|
|
124
|
+
for (const key of flagKeys) {
|
|
125
|
+
if (overrides[key] !== undefined) effective[key] = overrides[key];
|
|
126
|
+
else {
|
|
127
|
+
const value = configFlagValue(key);
|
|
128
|
+
if (value !== undefined) effective[key] = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { overrides, effective };
|
|
132
|
+
}
|
|
133
|
+
|
|
76
134
|
// Ensure the service has an embedder when the API layer was handed one
|
|
77
135
|
// (tests wire the embedder through the API instead of index.js). Without
|
|
78
136
|
// this, /api/dsh-mneme/search would silently degrade to keyword-only.
|
|
@@ -112,10 +170,26 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
112
170
|
? Number(minRaw)
|
|
113
171
|
: undefined;
|
|
114
172
|
const source = url.searchParams.get("source") || undefined;
|
|
115
|
-
|
|
173
|
+
// updatedFrom/updatedTo:updated_at 闭区间过滤(ISO 日期或完整时间戳)。
|
|
174
|
+
// 边界归一化在 store 的 updatedAtBounds(list/count 共用同一纯函数)
|
|
175
|
+
// 完成,非法值在那里被忽略——这里原样透传即可。
|
|
176
|
+
const updatedFrom = url.searchParams.get("updatedFrom") ?? undefined;
|
|
177
|
+
const updatedTo = url.searchParams.get("updatedTo") ?? undefined;
|
|
178
|
+
// archived=only:只看归档(状态页的归档列表用)。归档行不进默认列表,
|
|
179
|
+
// 所以这是独立的视图开关,而不是 includeArchived 的混看模式。
|
|
180
|
+
const onlyArchived = url.searchParams.get("archived") === "only";
|
|
181
|
+
const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived });
|
|
182
|
+
// 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
|
|
183
|
+
// schema 严格复用 toApiList,扩展只发生在 HTTP 层。
|
|
184
|
+
const items = service.toApiList(rows).map((m, i) => ({
|
|
185
|
+
...m,
|
|
186
|
+
archived: rows[i].archived === true || rows[i].archived === 1,
|
|
187
|
+
quality_score: rows[i].quality_score ?? null
|
|
188
|
+
}));
|
|
116
189
|
// Total honors the same filters as the rows, or the pager's
|
|
117
|
-
// has-more math breaks whenever minImportance/source
|
|
118
|
-
|
|
190
|
+
// has-more math breaks whenever minImportance/source/updated-at
|
|
191
|
+
// bounds are active.
|
|
192
|
+
sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived }) });
|
|
119
193
|
} catch {
|
|
120
194
|
sendJson(res, 500, { error: "internal" });
|
|
121
195
|
}
|
|
@@ -220,6 +294,90 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
220
294
|
}
|
|
221
295
|
});
|
|
222
296
|
|
|
297
|
+
// --- 编辑/归档一条记忆(面板写路径,与 /delete 对称命名)-------------------
|
|
298
|
+
// POST body {id, title?, content?, importance?, tags?, archived?}。字段校验
|
|
299
|
+
// 只做类型/范围检查:字段出现(!== undefined)就必须合法,宁 400 不静默纠正
|
|
300
|
+
// ——静默丢字段会让面板误以为保存成功。写入走 service 的正规更新路径:
|
|
301
|
+
// updated_at 由 store 的单调时钟推进;content 被改写时旧版本按 human_override
|
|
302
|
+
// 入档(与镜像人工编辑回灌 mergeHumanEdits 的语义对齐,FIFO 上限 20);
|
|
303
|
+
// archived 走 setArchived。两条路径都会触发镜像重渲染(afterSync)。
|
|
304
|
+
register({
|
|
305
|
+
kind: "exact",
|
|
306
|
+
path: "/api/dsh-mneme/update",
|
|
307
|
+
handler(req, res) {
|
|
308
|
+
try {
|
|
309
|
+
if (req.method !== "POST") {
|
|
310
|
+
sendJson(res, 404, { error: "not-found" });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
314
|
+
return readBody(req).then((text) => {
|
|
315
|
+
const body = parseBody(text);
|
|
316
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
317
|
+
if (!id) {
|
|
318
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const patch = {};
|
|
322
|
+
if (body.title !== undefined) {
|
|
323
|
+
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
324
|
+
sendJson(res, 400, { error: "invalid-title" });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
patch.title = body.title.trim();
|
|
328
|
+
}
|
|
329
|
+
if (body.content !== undefined) {
|
|
330
|
+
if (typeof body.content !== "string" || !body.content.trim()) {
|
|
331
|
+
sendJson(res, 400, { error: "invalid-content" });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
patch.content = body.content.trim();
|
|
335
|
+
}
|
|
336
|
+
if (body.importance !== undefined) {
|
|
337
|
+
if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
|
|
338
|
+
sendJson(res, 400, { error: "invalid-importance" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
patch.importance = body.importance;
|
|
342
|
+
}
|
|
343
|
+
if (body.tags !== undefined) {
|
|
344
|
+
if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
|
|
345
|
+
sendJson(res, 400, { error: "invalid-tags" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
patch.tags = body.tags;
|
|
349
|
+
}
|
|
350
|
+
if (body.archived !== undefined && typeof body.archived !== "boolean") {
|
|
351
|
+
sendJson(res, 400, { error: "invalid-archived" });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (Object.keys(patch).length === 0 && body.archived === undefined) {
|
|
355
|
+
sendJson(res, 400, { error: "no-fields" });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const existing = service.getById(id);
|
|
359
|
+
if (!existing) {
|
|
360
|
+
sendJson(res, 404, { error: "not-found" });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// 内容被改写时旧版本先入档(human_override),人工修正不静默销毁旧值。
|
|
364
|
+
if (patch.content !== undefined && patch.content !== existing.content) {
|
|
365
|
+
const history = Array.isArray(existing.content_history) ? existing.content_history : [];
|
|
366
|
+
patch.content_history = [
|
|
367
|
+
{ content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
|
|
368
|
+
...history
|
|
369
|
+
].slice(0, 20);
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(patch).length) service.update(id, patch);
|
|
372
|
+
if (body.archived !== undefined) service.setArchived(id, body.archived);
|
|
373
|
+
sendJson(res, 200, { memory: service.getById(id) });
|
|
374
|
+
});
|
|
375
|
+
} catch {
|
|
376
|
+
sendJson(res, 500, { error: "internal" });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
223
381
|
// --- rules ---
|
|
224
382
|
register({
|
|
225
383
|
kind: "exact",
|
|
@@ -501,6 +659,144 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
501
659
|
}
|
|
502
660
|
});
|
|
503
661
|
|
|
662
|
+
// --- 一条记忆的关联实体(记忆详情侧栏)-------------------------------------
|
|
663
|
+
// 只读:entity_attrs.memory_id 反查实体(store 一条 JOIN 完成,按提及去重)。
|
|
664
|
+
// 与目录 /entities 不同,这里以记忆为锚点,回答"这条记忆提到了谁"。记忆不
|
|
665
|
+
// 存在或无关联一律返回空数组——详情侧栏不需要区分这两种情况。
|
|
666
|
+
register({
|
|
667
|
+
kind: "exact",
|
|
668
|
+
path: "/api/dsh-mneme/memories/entities",
|
|
669
|
+
handler(req, res) {
|
|
670
|
+
try {
|
|
671
|
+
const url = new URL(req.url, "http://localhost");
|
|
672
|
+
const memoryId = (url.searchParams.get("memoryId") ?? "").trim();
|
|
673
|
+
if (!memoryId) {
|
|
674
|
+
sendJson(res, 400, { error: "missing-memory-id" });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
sendJson(res, 200, { entities: service.entitiesForMemory?.(memoryId) ?? [] });
|
|
678
|
+
} catch {
|
|
679
|
+
sendJson(res, 500, { error: "internal" });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// --- 导出(面板备份/迁移)---------------------------------------------------
|
|
685
|
+
// 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC;
|
|
686
|
+
// markdown:按类型分节,块格式与磁盘镜像完全同构(renderMirrorText 与
|
|
687
|
+
// mirror.sync 共用同一条渲染路径),因此导出文本可以被 /import 原样吃回。
|
|
688
|
+
// 全量一次性取回(store.all 按 updated_at DESC)——导出是一次性备份动作,
|
|
689
|
+
// 不需要流式。
|
|
690
|
+
register({
|
|
691
|
+
kind: "exact",
|
|
692
|
+
path: "/api/dsh-mneme/export",
|
|
693
|
+
handler(req, res) {
|
|
694
|
+
try {
|
|
695
|
+
const url = new URL(req.url, "http://localhost");
|
|
696
|
+
const format = url.searchParams.get("format") ?? "json";
|
|
697
|
+
if (format !== "json" && format !== "markdown") {
|
|
698
|
+
sendJson(res, 400, { error: "invalid-format" });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const rows = service.all?.() ?? [];
|
|
702
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
703
|
+
if (format === "json") {
|
|
704
|
+
sendJson(res, 200, {
|
|
705
|
+
exported_at: new Date().toISOString(),
|
|
706
|
+
version: PACKAGE_VERSION,
|
|
707
|
+
count: rows.length,
|
|
708
|
+
memories: rows
|
|
709
|
+
}, { "Content-Disposition": `attachment; filename="dsh-mneme-export-${stamp}.json"` });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const byType = {};
|
|
713
|
+
for (const row of rows) {
|
|
714
|
+
if (TYPE_FILE[row.type]) (byType[row.type] ??= []).push(row);
|
|
715
|
+
}
|
|
716
|
+
const sections = [];
|
|
717
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
718
|
+
if (byType[type]?.length) sections.push(renderMirrorText(type, byType[type]));
|
|
719
|
+
}
|
|
720
|
+
sendAttachment(res, 200, "text/markdown; charset=utf-8", `dsh-mneme-export-${stamp}.md`, sections.join("\n"));
|
|
721
|
+
} catch {
|
|
722
|
+
sendJson(res, 500, { error: "internal" });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// --- 导入(Markdown 镜像回填)----------------------------------------------
|
|
728
|
+
// 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键
|
|
729
|
+
// (preference/project/decision/history/summary),markdown 是与镜像文件同构
|
|
730
|
+
// 的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
|
|
731
|
+
// 行为一致是硬约束),合并走 mergeHumanEdits(只吃 title/content;digest 命
|
|
732
|
+
// 中或无差异的条目在 service 侧自动跳过)。解析出 0 条不算错误。
|
|
733
|
+
register({
|
|
734
|
+
kind: "exact",
|
|
735
|
+
path: "/api/dsh-mneme/import",
|
|
736
|
+
handler(req, res) {
|
|
737
|
+
try {
|
|
738
|
+
if (req.method !== "POST") {
|
|
739
|
+
sendJson(res, 404, { error: "not-found" });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
743
|
+
return readBody(req).then((text) => {
|
|
744
|
+
const body = parseBody(text);
|
|
745
|
+
if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
|
|
746
|
+
sendJson(res, 400, { error: "invalid-type" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (typeof body.markdown !== "string" || !body.markdown.trim()) {
|
|
750
|
+
sendJson(res, 400, { error: "invalid-markdown" });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const edits = parseHumanEdits(body.markdown);
|
|
754
|
+
service.mergeHumanEdits(body.type, edits);
|
|
755
|
+
sendJson(res, 200, { merged: edits.length, type: body.type });
|
|
756
|
+
});
|
|
757
|
+
} catch {
|
|
758
|
+
sendJson(res, 500, { error: "internal" });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// --- 巩固状态(dream 面板)--------------------------------------------------
|
|
764
|
+
// 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
|
|
765
|
+
// pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
|
|
766
|
+
// 压很大时响应失控。listDreamRuns/listConflictPending 走 service 现成的审计
|
|
767
|
+
// 只读通道(bookkeeping 语义,不触发写钩子)。
|
|
768
|
+
register({
|
|
769
|
+
kind: "exact",
|
|
770
|
+
path: "/api/dsh-mneme/dream-status",
|
|
771
|
+
handler(req, res) {
|
|
772
|
+
try {
|
|
773
|
+
const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
|
|
774
|
+
created_at: r.created_at,
|
|
775
|
+
status: r.status,
|
|
776
|
+
provider: r.provider ?? null,
|
|
777
|
+
model: r.model ?? null,
|
|
778
|
+
error: r.error ?? null
|
|
779
|
+
}));
|
|
780
|
+
const pendingConflicts = service.countConflictPending?.() ?? 0;
|
|
781
|
+
const ids = new Set();
|
|
782
|
+
for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
|
|
783
|
+
if (ids.size >= 200) break;
|
|
784
|
+
if (row.memory_a) ids.add(row.memory_a);
|
|
785
|
+
if (ids.size >= 200) break;
|
|
786
|
+
if (row.memory_b) ids.add(row.memory_b);
|
|
787
|
+
}
|
|
788
|
+
sendJson(res, 200, {
|
|
789
|
+
lastRun: runs[0] ?? null,
|
|
790
|
+
runs,
|
|
791
|
+
pendingConflicts,
|
|
792
|
+
pendingMemoryIds: [...ids].slice(0, 200)
|
|
793
|
+
});
|
|
794
|
+
} catch {
|
|
795
|
+
sendJson(res, 500, { error: "internal" });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
504
800
|
// --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
|
|
505
801
|
// Auth-gated; only returns a sanitized error code (never raw last_error which
|
|
506
802
|
// may leak paths/token-like strings/internal hosts). On state read failure it
|
|
@@ -624,6 +920,41 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
624
920
|
}
|
|
625
921
|
});
|
|
626
922
|
|
|
923
|
+
// --- feature flags(功能开关:面板逐项开关后端能力)-------------------------
|
|
924
|
+
// 读路由保持开放(与 mode/list 一致),前端无需 token 即可渲染开关状态;PUT
|
|
925
|
+
// 与其他设置写一致走 requireAuth。空/非对象 patch 直接 400:它不携带任何
|
|
926
|
+
// 意图,静默成功只会掩盖前端 bug。校验失败(未知键/类型/越界)的 400 把键
|
|
927
|
+
// 名放进 error,前端能直接定位写坏的开关。生效节奏与 panel_mode 相同:
|
|
928
|
+
// 持久化后下次启动合并进 cfg,本次启动的运行时行为不变。
|
|
929
|
+
register({
|
|
930
|
+
kind: "exact",
|
|
931
|
+
path: "/api/dsh-mneme/features",
|
|
932
|
+
handler(req, res) {
|
|
933
|
+
try {
|
|
934
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
935
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
936
|
+
return readBody(req).then((text) => {
|
|
937
|
+
const body = parseBody(text);
|
|
938
|
+
if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
939
|
+
sendJson(res, 400, { error: "invalid-patch" });
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
settings.setFeatureFlags(body);
|
|
944
|
+
} catch (error) {
|
|
945
|
+
sendJson(res, 400, { error: error.message });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
sendJson(res, 200, featureSnapshot());
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
sendJson(res, 200, featureSnapshot());
|
|
952
|
+
} catch {
|
|
953
|
+
sendJson(res, 500, { error: "internal" });
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
627
958
|
// --- custom commands ---
|
|
628
959
|
register({
|
|
629
960
|
kind: "exact",
|
|
@@ -662,7 +993,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
662
993
|
});
|
|
663
994
|
|
|
664
995
|
return {
|
|
665
|
-
routes:
|
|
996
|
+
routes: 23,
|
|
666
997
|
dispose: () => {
|
|
667
998
|
for (const dispose of disposers) dispose();
|
|
668
999
|
}
|
package/src/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/src/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/src/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
|
};
|