@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.
@@ -17,7 +17,7 @@
17
17
  import { randomUUID, createHash } from "node:crypto";
18
18
  import { validateDecisions, applyDecisions } from "./decisions.js";
19
19
  import { findPotentialConflicts } from "./clustering.js";
20
- import { buildReceipt } from "../dream.js";
20
+ import { buildReceipt, withEffortFallback } from "../dream.js";
21
21
 
22
22
  const SUMMARY_MAX = 120;
23
23
  // Conflict similarity threshold per strictness level (v0.4.0):
@@ -72,16 +72,18 @@ async function streamText(ctx, options) {
72
72
  return text;
73
73
  }
74
74
 
75
- /** LLM route: agent default model first, then sleepProvider/Model, then the
76
- * dream route as a shared fallback. Sleep can pin a cheaper model for its
77
- * bulk passes without disturbing the dream route. */
75
+ /** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
76
+ * route as a shared explicit fallback, then the agent default model. Sleep
77
+ * can pin a cheaper model for its bulk passes without disturbing the dream
78
+ * route. Explicit config first — otherwise the config routes are dead code
79
+ * whenever agentDefaultModel resolves (see resolveRoute in dream.js). */
78
80
  function resolveSleepRoute(ctx, config, logger) {
81
+ if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
82
+ if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
79
83
  try {
80
84
  const sel = ctx?.agentDefaultModel?.currentSelection?.();
81
85
  if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
82
- } catch { /* fall through to config route */ }
83
- if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
84
- if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
86
+ } catch { /* fall through to warn */ }
85
87
  logger?.warn?.("dsh-mneme sleep: no llm route available");
86
88
  return undefined;
87
89
  }
@@ -182,19 +184,19 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
182
184
  const listText = selected.map((p) =>
183
185
  `候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`
184
186
  ).join("\n\n");
185
- const text = await streamText(ctx, {
187
+ const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
188
+ const runConflict = (withEffort) => streamText(ctx, {
186
189
  provider: route.provider,
187
190
  model: route.model,
188
191
  purpose: "sleep-conflict",
189
192
  maxTokens: 2048,
190
- ...(config.sleepReasoningEffort && config.sleepReasoningEffort !== "none"
191
- ? { reasoningEffort: config.sleepReasoningEffort }
192
- : {}),
193
+ ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
193
194
  messages: [
194
195
  { role: "system", content: [{ type: "text", text: CONFLICT_PROMPT }] },
195
196
  { role: "user", content: [{ type: "text", text: listText }] }
196
197
  ]
197
198
  });
199
+ const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false));
198
200
  if (text === undefined) return { status: "failed", error: "llm failed" };
199
201
  const decisions = parseJsonArray(text);
200
202
  if (!decisions) return { status: "failed", error: "invalid decisions json" };
@@ -282,19 +284,19 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
282
284
  .map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
283
285
  .join("\n");
284
286
  const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
285
- const text = await streamText(ctx, {
287
+ const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
288
+ const runPattern = (withEffort) => streamText(ctx, {
286
289
  provider: route.provider,
287
290
  model: route.model,
288
291
  purpose: "sleep-pattern",
289
292
  maxTokens: 2048,
290
- ...(config.sleepReasoningEffort && config.sleepReasoningEffort !== "none"
291
- ? { reasoningEffort: config.sleepReasoningEffort }
292
- : {}),
293
+ ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
293
294
  messages: [
294
295
  { role: "system", content: [{ type: "text", text: PATTERN_PROMPT.replace("N", String(maxPatterns)) }] },
295
296
  { role: "user", content: [{ type: "text", text: listText }] }
296
297
  ]
297
298
  });
299
+ const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false));
298
300
  if (text === undefined) return { status: "failed", error: "llm failed" };
299
301
  const decisions = parseJsonArray(text);
300
302
  if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
package/lib/dream.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
2
  import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- export { validateDecisions, applyDecisions };
4
+ export { validateDecisions, applyDecisions, withEffortFallback };
5
5
 
6
6
 
7
7
  // Extract the first JSON array from LLM output, tolerating markdown fences,
@@ -282,6 +282,15 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
282
282
  // record it as error here so the audit shows the truth.
283
283
  status = "error";
284
284
  errorMessage = errorMessage ?? "llm stream aborted or errored";
285
+ } else if (typeof spec.auditError === "function") {
286
+ // A stream that returned text but yields nothing usable is still a
287
+ // failed call — record it as error, not the default success, so the
288
+ // audit no longer contradicts a failed run (dream "no json array").
289
+ const message = spec.auditError(result);
290
+ if (message) {
291
+ status = "error";
292
+ errorMessage = message;
293
+ }
285
294
  }
286
295
  return result;
287
296
  } catch (error) {
@@ -311,20 +320,50 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
311
320
  }
312
321
 
313
322
  /**
314
- * Resolve the LLM route: agent default model (deployment) first, plugin config
315
- * (dreamProvider/dreamModel) as fallback. Falls through to undefined when no
316
- * route exists runDream then fails safe. Fallback is logged so a silent
317
- * route switch is observable.
323
+ * Reasoning-effort rejection fallback (v0.8.1): a configured dreamReasoningEffort
324
+ * / sleepReasoningEffort may be rejected by the provider (volcano-engine returns
325
+ * UNSUPPORTED_REASONING_EFFORT for values it does not accept "off" is known
326
+ * rejected there). When that happens, retry once WITHOUT the reasoning field
327
+ * instead of hard-failing the run, so effort config is safe to experiment with:
328
+ * accepted → reasoning capped; rejected → provider default (old behavior),
329
+ * logged so the rejection is observable.
330
+ */
331
+ async function withEffortFallback(ctx, effort, attempt, fallback) {
332
+ if (!effort || effort === "none") return attempt();
333
+ try {
334
+ return await attempt();
335
+ } catch (error) {
336
+ const message = String(error?.message ?? error);
337
+ // matches both "reasoning effort" (natural language) and the bare
338
+ // "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
339
+ if (!/reasoning[\s_]*effort/i.test(message)) throw error;
340
+ ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected (${message}); retrying without it`);
341
+ return fallback();
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Resolve the LLM route (Issue #25): an explicit plugin config
347
+ * (dreamProvider/dreamModel) is the user's declared override and wins; the
348
+ * agent default model (deployment) is only a fallback when no config route is
349
+ * set. In a standard DSH install agentDefaultModel always resolves, so without
350
+ * this ordering the config route would be dead code and dreamProvider/dreamModel
351
+ * could never take effect (v0.7.11 regressed this; README §config documents
352
+ * config-first). Falls through to undefined when no route exists — runDream
353
+ * then fails safe. A config→default switch is logged so it is observable.
318
354
  */
319
355
  function resolveRoute(ctx, config, logger) {
356
+ if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
320
357
  try {
321
358
  const sel = ctx.agentDefaultModel?.currentSelection?.();
322
- if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
323
- logger?.warn?.("dsh-mneme dream: agentDefaultModel unavailable, falling back to config route");
359
+ if (sel?.provider && sel?.model) {
360
+ logger?.info?.("dsh-mneme dream: no dreamProvider/dreamModel config, falling back to agent default");
361
+ return { provider: sel.provider, model: sel.model };
362
+ }
363
+ logger?.warn?.("dsh-mneme dream: agentDefaultModel unavailable, no config route either");
324
364
  } catch (error) {
325
- logger?.warn?.(`dsh-mneme dream: agentDefaultModel lookup failed, falling back to config route: ${String(error)}`);
365
+ logger?.warn?.(`dsh-mneme dream: agentDefaultModel lookup failed: ${String(error)}`);
326
366
  }
327
- if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
328
367
  return undefined;
329
368
  }
330
369
 
@@ -585,28 +624,37 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
585
624
  ? CONSOLIDATION_PROMPT + `\n\n当前为「冲突冻结」模式:检测到内容矛盾的条目时,仍请输出 conflict,并以 winner/loser 作为候选、reason 说明理由;冲突不会被自动裁决,而会冻结待人工确认。`
586
625
  : CONSOLIDATION_PROMPT;
587
626
  let decisionText;
627
+ // 加固(v0.8.1):配置的 reasoningEffort 被 provider 拒收时回退重试一次
628
+ // (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
629
+ // 在 auditError 检查器里、闭包交回主流程,避免二次解析;解析失败同时如实
630
+ // 记 audit error 并在日志带原始输出前 300 字节,便于定位"推理吞预算返回空体"。
631
+ const effort = config.dreamReasoningEffort && config.dreamReasoningEffort !== "none" ? config.dreamReasoningEffort : null;
632
+ let decisions = null;
633
+ const runConsolidation = (withEffort) => runAuditedLlm(ctx, service, config, {
634
+ triggerSource: "autoDream",
635
+ operationType: "dream_consolidate",
636
+ modelId: `${route.provider}:${route.model}`,
637
+ relatedMemoryIds: [...snapshot.keys()],
638
+ auditError: (text) => {
639
+ decisions = extractJsonArray(text);
640
+ return Array.isArray(decisions) ? null : "no json array in llm output";
641
+ }
642
+ }, (reportUsage) => streamText(ctx, {
643
+ provider: route.provider,
644
+ model: route.model,
645
+ purpose: "compaction",
646
+ maxTokens: config.dreamMaxTokens ?? 4096,
647
+ ...(withEffort && effort ? { reasoningEffort: effort } : {}),
648
+ messages: [
649
+ { role: "system", content: [{ type: "text", text: consolidationPrompt }] },
650
+ { role: "user", content: [{ type: "text", text: listText }] }
651
+ ]
652
+ }, reportUsage));
588
653
  try {
589
654
  // Bug8: the consolidation call is audited (tokens/time/status). A throw
590
655
  // re-propagates to the catch below; an aborted stream returns undefined
591
656
  // and is treated as a failed run after the check below.
592
- decisionText = await runAuditedLlm(ctx, service, config, {
593
- triggerSource: "autoDream",
594
- operationType: "dream_consolidate",
595
- modelId: `${route.provider}:${route.model}`,
596
- relatedMemoryIds: [...snapshot.keys()]
597
- }, (reportUsage) => streamText(ctx, {
598
- provider: route.provider,
599
- model: route.model,
600
- purpose: "compaction",
601
- maxTokens: config.dreamMaxTokens ?? 4096,
602
- ...(config.dreamReasoningEffort && config.dreamReasoningEffort !== "none"
603
- ? { reasoningEffort: config.dreamReasoningEffort }
604
- : {}),
605
- messages: [
606
- { role: "system", content: [{ type: "text", text: consolidationPrompt }] },
607
- { role: "user", content: [{ type: "text", text: listText }] }
608
- ]
609
- }, reportUsage));
657
+ decisionText = await withEffortFallback(ctx, effort, () => runConsolidation(true), () => runConsolidation(false));
610
658
  } catch (error) {
611
659
  logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
612
660
  return finish({ ok: false, error: "llm failed", summary: false });
@@ -615,10 +663,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
615
663
  logger?.warn?.("dsh-mneme dream: consolidation llm stream aborted or errored");
616
664
  return finish({ ok: false, error: "llm failed", summary: false });
617
665
  }
618
-
619
- const decisions = extractJsonArray(decisionText);
620
666
  if (!Array.isArray(decisions)) {
621
- logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0})`);
667
+ const head = (decisionText ?? "").slice(0, 300).replace(/\s+/g, " ").trim();
668
+ logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
622
669
  return finish({ ok: false, error: "no json array in llm output", summary: false });
623
670
  }
624
671
  const { ok, errors } = validateDecisions(decisions, snapshot, {
@@ -735,26 +782,25 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
735
782
  // Summary generation (second LLM call). A throwing stream is reported as
736
783
  // a failed run; summary:false marks a run that produced no summary.
737
784
  let summaryText;
785
+ const runSummary = (withEffort) => runAuditedLlm(ctx, service, config, {
786
+ triggerSource: "autoDream",
787
+ operationType: "dream_summarize",
788
+ modelId: `${route.provider}:${route.model}`,
789
+ relatedMemoryIds: []
790
+ }, (reportUsage) => streamText(ctx, {
791
+ provider: route.provider,
792
+ model: route.model,
793
+ purpose: "compaction",
794
+ maxTokens: config.dreamMaxTokens ?? 2048,
795
+ ...(withEffort && effort ? { reasoningEffort: effort } : {}),
796
+ messages: [
797
+ { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
798
+ { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
799
+ ]
800
+ }, reportUsage));
738
801
  try {
739
802
  // Bug8: the summary call is audited too (operation dream_summarize).
740
- summaryText = await runAuditedLlm(ctx, service, config, {
741
- triggerSource: "autoDream",
742
- operationType: "dream_summarize",
743
- modelId: `${route.provider}:${route.model}`,
744
- relatedMemoryIds: []
745
- }, (reportUsage) => streamText(ctx, {
746
- provider: route.provider,
747
- model: route.model,
748
- purpose: "compaction",
749
- maxTokens: config.dreamMaxTokens ?? 2048,
750
- ...(config.dreamReasoningEffort && config.dreamReasoningEffort !== "none"
751
- ? { reasoningEffort: config.dreamReasoningEffort }
752
- : {}),
753
- messages: [
754
- { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
755
- { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
756
- ]
757
- }, reportUsage));
803
+ summaryText = await withEffortFallback(ctx, effort, () => runSummary(true), () => runSummary(false));
758
804
  } catch (error) {
759
805
  logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
760
806
  return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, frozen: frozenCount, summary: false });
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
- 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/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
- * 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/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
  };