@modusensus/dsh-mneme 0.7.14 → 0.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -2
- package/README.md +6 -4
- package/lib/api.js +338 -7
- package/lib/client.js +1238 -204
- package/lib/dream/sleep.js +17 -15
- package/lib/dream.js +94 -48
- package/lib/index.js +25 -2
- package/lib/mirror.js +93 -59
- package/lib/service.js +2 -0
- package/lib/settings.js +174 -0
- package/lib/store.js +77 -4
- package/package.json +12 -1
- package/src/api.js +338 -7
- package/src/dream/sleep.js +17 -15
- package/src/dream.js +94 -48
- package/src/index.js +25 -2
- package/src/mirror.js +93 -59
- package/src/service.js +2 -0
- package/src/settings.js +174 -0
- package/src/store.js +77 -4
- package/test/api.test.js +485 -2
- package/test/client.test.js +82 -52
- package/test/dream.test.js +1 -1
- package/test/helpers/peer-worker.mjs +17 -2
- package/test/llm-audit.test.js +34 -4
- package/test/peer-blockers.test.js +8 -2
- package/test/reasoning-effort.test.js +64 -0
- package/test/settings.test.js +136 -0
package/src/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
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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)
|
|
323
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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/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
|
};
|
package/src/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
|
}
|