@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/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
- function sendJson(res, status, payload) {
5
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
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
- const items = service.toApiList(service.list({ type, limit, offset, order, minImportance, source }));
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 is active.
118
- sendJson(res, 200, { items, total: service.count(type, { minImportance, source }) });
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: 17,
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
- const cfg = applyLightModePreset({ ...rawCfg, lightMode });
69
+ // 功能开关合并顺序即优先级:用户显式开关(feature_flags kv,面板写入)>
70
+ // 轻量预设(applyLightModePreset 批量置关的重型能力)> bundle 配置。预设必须
71
+ // 先应用、用户开关后展开,否则 LIGHT_MODE_OFF 会把用户显式打开的开关再次
72
+ // 压掉。合并结果只作用于本次启动:面板改开关后与 panel_mode 一样在下次
73
+ // 启动生效。
74
+ // 嵌套对象开关按首个点号拆开(kv 里平铺存的 "memoryQualityFilter.enabled" →
75
+ // cfg.memoryQualityFilter.enabled),点号键不原样留在 cfg 顶层属性里。
76
+ const flags = settings.getFeatureFlags();
77
+ const flatFlags = {};
78
+ const nestedFlags = {};
79
+ for (const [key, value] of Object.entries(flags)) {
80
+ const dot = key.indexOf(".");
81
+ if (dot > 0) {
82
+ const objKey = key.slice(0, dot);
83
+ const subKey = key.slice(dot + 1);
84
+ nestedFlags[objKey] = { ...(nestedFlags[objKey] ?? {}), [subKey]: value };
85
+ } else {
86
+ flatFlags[key] = value;
87
+ }
88
+ }
89
+ const cfg = { ...applyLightModePreset({ ...rawCfg, lightMode }), ...flatFlags };
90
+ for (const [objKey, sub] of Object.entries(nestedFlags)) {
91
+ cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
92
+ }
70
93
 
71
94
  const mirror = createMirror(memoryDir);
72
95
  const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
@@ -340,7 +363,7 @@ export const apply = (ctx, config) => {
340
363
  add: () => { throw new Error("commands unavailable"); },
341
364
  remove: () => false,
342
365
  list: () => []
343
- }, embedder, { vectorIndex, reranker }, cfg.apiToken);
366
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg);
344
367
  disposers.push(api.dispose);
345
368
  }
346
369
 
package/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
- * Parse a mirror file back into {id, title, content} entries for human edits.
60
- * Entries are anchored on "- **ID**: `...`" lines that are followed by the
61
- * "- **类型**:" metadata line (structural entry head): each entry's block
62
- * spans from its ID line up to the next ID line (or end of file). The block
63
- * head (the ID line plus the generated metadata run) and the trailing
64
- * structural "---" separator are stripped; everything in between is the entry
65
- * body, so user content containing "---", metadata-like lines, or even a
66
- * machine-format "- **ID**: `x`" line is preserved. The title is the "## "
67
- * heading preceding the ID line.
145
+ * Read the mirror files and parse them back into human edits. The pure
146
+ * parsing logic lives in the exported parseHumanEdits (shared with /import);
147
+ * this wrapper only owns the "read file text" side.
68
148
  */
69
149
  function readHumanEdits(type = undefined) {
70
150
  const types = type ? [type] : Object.keys(TYPE_FILE);
@@ -72,53 +152,7 @@ export function createMirror(dir) {
72
152
  for (const t of types) {
73
153
  const file = filePath(t);
74
154
  if (!file || !existsSync(file)) continue;
75
- const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
76
- // Anchor on the ID line only when it is a structural entry head: the
77
- // machine-rendered ID line is always followed by the "- **类型**:" line.
78
- // A body line like "- **ID**: `x`" is not, so it never splits the block
79
- // or produces a ghost entry.
80
- const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
81
- let prevEnd = 0;
82
- for (let i = 0; i < anchors.length; i++) {
83
- const anchor = anchors[i];
84
- const blockStart = anchor.index;
85
- const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
86
-
87
- // Title: last "## " heading before this ID line (file header region /
88
- // previous block tail). Body headings of earlier entries come before
89
- // the structural "---" + "## " of this entry, so the last match wins.
90
- const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
91
- const titleMatch = titleMatches[titleMatches.length - 1];
92
-
93
- // Body: the ID line and the generated metadata run are structural head;
94
- // everything after them up to the trailing "---" separator is the body.
95
- let body = text
96
- .slice(blockStart, blockEnd)
97
- .replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
98
- .replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "")
99
- .replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
100
- const separators = [...body.matchAll(/^---\s*$/gm)];
101
- const lastSep = separators[separators.length - 1];
102
- if (lastSep) body = body.slice(0, lastSep.index);
103
- body = body.trim();
104
-
105
- // The machine-written "更新时间" line records the store's updated_at at
106
- // render time — the version token for detecting a concurrent store write
107
- // during a three-way merge of human edits (see service.syncMirror).
108
- const block = text.slice(blockStart, blockEnd);
109
- const updatedMatch = block.match(/- \*\*更新时间\*\*: ([^\n]+)/);
110
- const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
111
- edits.push({
112
- id: anchor[1],
113
- title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
114
- content: body,
115
- updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
116
- digest: digestMatch ? digestMatch[1] : undefined
117
- });
118
-
119
- const lineEnd = text.indexOf("\n", blockStart);
120
- prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
121
- }
155
+ edits.push(...parseHumanEdits(readFileSync(file, "utf8")));
122
156
  }
123
157
  return edits;
124
158
  }
@@ -145,9 +179,9 @@ export function createMirror(dir) {
145
179
  // memories do not "resurrect" via readHumanEdits
146
180
  rmSync(file, { force: true });
147
181
  } else {
148
- const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
149
- const body = items.map(renderMemory).join("\n");
150
- writeFileSync(file, header + body, "utf8");
182
+ // 渲染走 renderMirrorText(与 /export 共用同一条渲染路径),磁盘镜像
183
+ // 与导出文本永远同构。
184
+ writeFileSync(file, renderMirrorText(type, items), "utf8");
151
185
  }
152
186
  results[type] = { ok: true };
153
187
  } catch (error) {
package/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
  };