@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1
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.md +51 -20
- package/bin/dsh-mneme-choro.mjs +9 -1
- package/docs/CONFIGURATION.md +47 -6
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +788 -30
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +4627 -1409
- package/lib/config.js +166 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +95 -6
- package/lib/dream/sleep.js +760 -19
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +168 -21
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +22 -11
- package/lib/index.js +123 -3
- package/lib/inject.js +203 -20
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +98 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +268 -69
- package/lib/settings.js +128 -15
- package/lib/store.js +585 -26
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/claims.js +267 -0
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +282 -2
- package/lib/wiki/render.js +74 -0
- package/lib/wiki/t0.js +43 -6
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +145 -32
- package/package.json +39 -1
- package/scripts/release-docs.mjs +7 -1
- package/scripts/release-notes.mjs +4 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +788 -30
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +166 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +95 -6
- package/src/dream/sleep.js +760 -19
- package/src/dream/triage.js +139 -0
- package/src/dream.js +168 -21
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +22 -11
- package/src/index.js +123 -3
- package/src/inject.js +203 -20
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +98 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +268 -69
- package/src/settings.js +128 -15
- package/src/store.js +585 -26
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/claims.js +267 -0
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +282 -2
- package/src/wiki/render.js +74 -0
- package/src/wiki/t0.js +43 -6
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +145 -32
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +385 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +1051 -185
- package/test/conflict-freeze.test.js +45 -1
- package/test/conflict-ledger-enforce.test.js +452 -0
- package/test/conflict-ledger.test.js +242 -0
- package/test/continuity.test.js +8 -1
- package/test/contrast.test.js +124 -12
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-baseline-clamp.test.js +174 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/graph-api.test.js +124 -0
- package/test/hot-memory.test.js +48 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-parent-gate.test.js +6 -3
- package/test/inject.test.js +59 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/panel-chart-scale.test.js +90 -0
- package/test/panel-tokens.test.js +245 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/release-docs.test.js +20 -0
- package/test/release-notes.test.js +25 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/security-doc.test.js +10 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +635 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +463 -1
- package/test/wiki-claims.test.js +194 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-provenance.test.js +202 -0
- package/test/wiki-queries.test.js +128 -0
- package/test/wiki-t0.test.js +77 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-t1.test.js +226 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// 冲突分诊(issue #641 beta 第一阶段,shadow 档):把 findPotentialConflicts 送进
|
|
2
|
+
// 冲突相位的每一对,先过确定性分诊,落进 conflict_ledger 账本。
|
|
3
|
+
//
|
|
4
|
+
// 为什么分诊先行:真实库 788 对人审队列的全量分类(只读挖掘,口径见 #641 正文)
|
|
5
|
+
// ——46% 近重复 + 20% 跨作用域重复 + 5% 时序演进,真矛盾只有 ~27%。前 71% 是该被
|
|
6
|
+
// 机械处置(merge / 去重 / 取代链)的工作量,现行窄 prompt(conflict/keep 二选一)
|
|
7
|
+
// 把它们全逼成了「输赢」,再由人来逐对裁决(≈30 条/天,且 92% 的对从未两侧同场)。
|
|
8
|
+
// 分诊把「谁该机械处理、谁才是真矛盾」从 LLM 手里拿回来:E13b 实测 winner-take-all
|
|
9
|
+
// 裁决 P(毒胜出)=86.8%,LLM 在这条链上只允许降级为分类器。
|
|
10
|
+
//
|
|
11
|
+
// 四档判据(全部确定性,本文件是唯一实现;enforce 阶段的处置接线另有 PR,本文件
|
|
12
|
+
// 在 shadow 档只产分类与信号,不做任何记忆行处置):
|
|
13
|
+
// cross-scope-dup 任一 scope 维键不同——同一内容落两个归属(#170 scopeReason 口径,
|
|
14
|
+
// 真实库占 20%)
|
|
15
|
+
// dup 同 scope + sim ≥ dupMin + created_at 差 < 24h——同日双录
|
|
16
|
+
// (真实库 76% 的对两侧差 <1 天;#616 实测 55-57% 的新条反而更短,
|
|
17
|
+
// 新鲜度对这类对无区分力,后续处置按信息量裁决)
|
|
18
|
+
// evolution 同 scope + created_at 差 ≥ 7 天——时序演进展期(T3 superseded_by
|
|
19
|
+
// 取代链的候选池,enforce 阶段接线;entity_attrs 失效信号并入也在
|
|
20
|
+
// 那一步,见 #585)
|
|
21
|
+
// conflict 其余——真矛盾残余(真实库 ~27%;账本标记,消解推迟到检索时/
|
|
22
|
+
// 会话纠正/巩固重蒸馏三时刻)
|
|
23
|
+
//
|
|
24
|
+
// 「未标注 scope」不算跨作用域:scopeKeyOf 把 NULL 归一成 null,两条都未标注时
|
|
25
|
+
// null !== null 为 false、判同档——#616 指出这个判据用于**自动归档**是洞(归属未知
|
|
26
|
+
// ≠归属相同),但在 shadow 分诊里零处置、无害;signals.annotated=false 显式落账,
|
|
27
|
+
// 让分析时能把「未标注」切出来,而不是让它静默混进同 scope 桶。
|
|
28
|
+
import { scopeKeyOf } from "../scope.js";
|
|
29
|
+
|
|
30
|
+
const DAY_MS = 86_400_000;
|
|
31
|
+
|
|
32
|
+
// T1(dup)与 T3(evolution)的时间界。24h 来自真实库分布(76% 的对 <1 天,且
|
|
33
|
+
// 1-7 天区间的「疑似同日双录」残留在 1-7 天档里很难与快速演进区分,宁保守归
|
|
34
|
+
// conflict——分诊错档在 shadow 期只是统计噪音,enforce 后错档会错杀);7 天是
|
|
35
|
+
// 演进展期的保守下限,真实库 7 天以上的对仅 0.5%(4/788)。
|
|
36
|
+
export const DUP_MAX_AGE_MS = DAY_MS;
|
|
37
|
+
export const EVOLUTION_MIN_AGE_MS = 7 * DAY_MS;
|
|
38
|
+
|
|
39
|
+
export const LEDGER_TIERS = ["dup", "cross-scope-dup", "evolution", "conflict"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Classify one candidate pair into a ledger tier. Pure function — no I/O, no
|
|
43
|
+
* config dependency beyond the tunables passed in, so the same verdict is
|
|
44
|
+
* reproducible from the recorded signals alone.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} a candidate side A (needs agent_scope, workspace_scope, created_at)
|
|
47
|
+
* @param {object} b candidate side B
|
|
48
|
+
* @param {number} similarity detector cosine similarity (pair already passed the
|
|
49
|
+
* detection threshold, so ≥ ~0.75-0.92 depending on strictness)
|
|
50
|
+
* @param {{dupMinSimilarity?: number, now?: number}} opts dupMin 默认 0.90(#616
|
|
51
|
+
* 重测:0.95 在现库覆盖为 0,真实库 reason 相似度回声集中在 0.85-0.93,
|
|
52
|
+
* 0.90 是把回声带接进 dup 档的落点);now 仅测试注入
|
|
53
|
+
* @returns {{tier: string, signals: object}}
|
|
54
|
+
*/
|
|
55
|
+
export function triagePair(a, b, similarity, opts = {}) {
|
|
56
|
+
const dupMin = typeof opts.dupMinSimilarity === "number" ? opts.dupMinSimilarity : 0.9;
|
|
57
|
+
const now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
58
|
+
|
|
59
|
+
const crossScope =
|
|
60
|
+
scopeKeyOf(a.agent_scope) !== scopeKeyOf(b.agent_scope) ||
|
|
61
|
+
scopeKeyOf(a.workspace_scope) !== scopeKeyOf(b.workspace_scope);
|
|
62
|
+
const annotated = (a.agent_scope != null || a.workspace_scope != null) &&
|
|
63
|
+
(b.agent_scope != null || b.workspace_scope != null);
|
|
64
|
+
|
|
65
|
+
// created_at 缺失不下结论:宁归 conflict(账本里最保守的一档),也不编造时间信号。
|
|
66
|
+
const createdA = Date.parse(a.created_at ?? "");
|
|
67
|
+
const createdB = Date.parse(b.created_at ?? "");
|
|
68
|
+
const gapKnown = Number.isFinite(createdA) && Number.isFinite(createdB);
|
|
69
|
+
const gapDays = gapKnown ? Math.abs(createdA - createdB) / DAY_MS : null;
|
|
70
|
+
|
|
71
|
+
let tier;
|
|
72
|
+
if (crossScope) {
|
|
73
|
+
tier = "cross-scope-dup";
|
|
74
|
+
} else if (gapKnown && typeof similarity === "number" && similarity >= dupMin && gapDays < DUP_MAX_AGE_MS / DAY_MS) {
|
|
75
|
+
tier = "dup";
|
|
76
|
+
} else if (gapKnown && gapDays * DAY_MS >= EVOLUTION_MIN_AGE_MS) {
|
|
77
|
+
tier = "evolution";
|
|
78
|
+
} else {
|
|
79
|
+
tier = "conflict";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
tier,
|
|
84
|
+
signals: {
|
|
85
|
+
crossScope,
|
|
86
|
+
annotated,
|
|
87
|
+
gapDays: gapDays == null ? null : Math.round(gapDays * 1000) / 1000,
|
|
88
|
+
dupMin,
|
|
89
|
+
similarity: typeof similarity === "number" ? similarity : null
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Triage + record every selected pair into the conflict_ledger. Bookkeeping
|
|
96
|
+
* only: a store failure on one pair must never fail the phase (same
|
|
97
|
+
* best-effort discipline as saveConflictReviewed / saveDifferentiatedPair —
|
|
98
|
+
* 记账本身绝不能反过来把巩固打断)。
|
|
99
|
+
*
|
|
100
|
+
* 分块 + 让出(#643 review):候选池大时同步写完全部对会连续占用事件循环、
|
|
101
|
+
* 延迟用户活动与取消信号的处理——每块之间 setImmediate 让出一次,并在每块
|
|
102
|
+
* 开始前检查 signal.aborted。取消后停止记账,余下的对下一轮自然重见;**不用
|
|
103
|
+
* 固定上限裁剪观察样本**(shadow 的分母必须完整,截断只来自取消,且在
|
|
104
|
+
* tally.truncatedAt 可见——静默截断会让分布失真,比没有数据更糟)。
|
|
105
|
+
*
|
|
106
|
+
* @returns Promise<tally> {dup, cross-scope-dup, evolution, conflict, recorded,
|
|
107
|
+
* failed, truncatedAt} — truncatedAt 非 null 表示取消点(已处理的对数)
|
|
108
|
+
*/
|
|
109
|
+
export async function recordLedgerTriage(service, runId, pairs, opts = {}) {
|
|
110
|
+
const tally = { dup: 0, "cross-scope-dup": 0, evolution: 0, conflict: 0, recorded: 0, failed: 0, truncatedAt: null };
|
|
111
|
+
const CHUNK = 50;
|
|
112
|
+
for (let start = 0; start < pairs.length; start += CHUNK) {
|
|
113
|
+
// 块间让出事件循环(首块前不用——pairs 为空或单块时零额外宏任务)
|
|
114
|
+
if (start > 0) await new Promise((resolve) => setImmediate(resolve));
|
|
115
|
+
if (opts.signal?.aborted) {
|
|
116
|
+
tally.truncatedAt = start;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
for (const p of pairs.slice(start, start + CHUNK)) {
|
|
120
|
+
try {
|
|
121
|
+
const verdict = triagePair(p.a, p.b, p.similarity, opts);
|
|
122
|
+
service.saveConflictLedgerEntry({
|
|
123
|
+
run_id: runId,
|
|
124
|
+
memory_a: p.a.id,
|
|
125
|
+
memory_b: p.b.id,
|
|
126
|
+
tier: verdict.tier,
|
|
127
|
+
similarity: p.similarity,
|
|
128
|
+
signals: verdict.signals
|
|
129
|
+
});
|
|
130
|
+
tally[verdict.tier] += 1;
|
|
131
|
+
tally.recorded += 1;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
tally.failed += 1;
|
|
134
|
+
opts.logger?.warn?.(`dsh-mneme sleep: failed to record ledger triage for ${p.a?.id}/${p.b?.id}: ${String(error)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return tally;
|
|
139
|
+
}
|
package/lib/dream.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
1
|
+
import { validateDecisions, applyDecisions, supersedeInfoFirstPair } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
|
|
3
|
+
import { triagePair } from "./dream/triage.js";
|
|
3
4
|
import { clusterByTag, intersectEvidence } from "./dream/narratives.js";
|
|
4
5
|
import { scopeKeyOf } from "./scope.js";
|
|
5
6
|
// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」从独立的
|
|
@@ -164,6 +165,17 @@ export function buildOutcome(decisions) {
|
|
|
164
165
|
byId[d.loser] = "superseded-archived";
|
|
165
166
|
} else if (d.action === "differentiate") {
|
|
166
167
|
for (const id of d.ids ?? []) byId[id] = "differentiated";
|
|
168
|
+
} else if (d.action === "create") {
|
|
169
|
+
// #619:create 铸出新行——记下「这一条是这一轮新生的」。它此前特意不派
|
|
170
|
+
// disposition,好让 receipt_chain 不为它产行(新行的改动无处可记,只有
|
|
171
|
+
// memory_mints 记出生)。这里补上的是审计面能读懂的那句话,不改 receipt_chain。
|
|
172
|
+
if (d.id) byId[d.id] = "created";
|
|
173
|
+
} else if (d.action === "create_merged") {
|
|
174
|
+
// #619 / Ephor 第七轮 ②:create 撞上同题活跃行时走原地刷新,没有新条目。
|
|
175
|
+
// 这条动作原本在 if 链里缺席,`outcome.byId` 就是空对象——一个只刷新不铸造
|
|
176
|
+
// 的轮,审计行看不出任何目标被处置过(#126 review 在 supersede 上踩过同一个
|
|
177
|
+
// 坑)。刷新有自己的名字,不冒充 created。
|
|
178
|
+
if (d.id) byId[d.id] = "created-refreshed";
|
|
167
179
|
}
|
|
168
180
|
}
|
|
169
181
|
return { byId };
|
|
@@ -956,7 +968,7 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
|
|
|
956
968
|
// 用户配得比这更大的 dreamMinIntervalMinutes 基数压小(见 effectiveMinIntervalMs)。
|
|
957
969
|
const FAILURE_BACKOFF_CAP_MS = 30 * 60 * 1000;
|
|
958
970
|
|
|
959
|
-
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, failureBackoff = false, logger, semantic = null, lastRunAtSeed = 0, baselineSeed = null, peakHours = "", peakMaxDeferMinutes = 120, auditPeakSkip = null, now = () => Date.now(), setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout }) {
|
|
971
|
+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, failureBackoff = false, logger, semantic = null, lastRunAtSeed = 0, baselineSeed = null, peakHours = "", peakMaxDeferMinutes = 120, auditPeakSkip = null, auditBaselineClamp = null, now = () => Date.now(), setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout }) {
|
|
960
972
|
let pendingTimer = null;
|
|
961
973
|
// Issue #239(第 4 项)镜像到巩固:高峰顺延定时器。与 pendingTimer 分开——两者
|
|
962
974
|
// 语义不同(一个是「马上要跑」,一个是「等出高峰再跑」),合成一个变量会让
|
|
@@ -995,6 +1007,27 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
995
1007
|
|
|
996
1008
|
function shouldTrigger(service) {
|
|
997
1009
|
const { count, chars } = activeStoreSize(service);
|
|
1010
|
+
// Issue #634(基线夹回):基线只在成功轮前进(见下方 startRun 与 #89 注释),
|
|
1011
|
+
// 而归档/删除/合并把活跃量压下去——活跃量一旦跌破基线,overBase 恒为假,
|
|
1012
|
+
// 从此再也不会巩固,且全程静默(#634 实测停摆 13 天 / 22 小时零轮)。修法:
|
|
1013
|
+
// 判定前把基线夹回当前活跃量(每轴取 min),阈值从新的低点重新起算,归掉的
|
|
1014
|
+
// 量不必重新长回来。只动闭包内存、不落库:下一次成功轮本来就会把当时的规模
|
|
1015
|
+
// 写成新基线(startRun 的 result.baseline / 现场计算),重启后种子若仍是旧峰值,
|
|
1016
|
+
// 第一次判定在这里同样被夹回——所以不需要新的持久化形状。收缩的来源不止归档
|
|
1017
|
+
// (删除、合并、暂停作用域、外来行占比变化同效果),且归档路径不触发
|
|
1018
|
+
// notifyWrite(service.notifyWrite:状态开关不算内容写入),逐写路径维护基线
|
|
1019
|
+
// 碰的面大、漏面也大——只在判定口一处收敛。夹回是罕见事件(每次「收缩→再触发」
|
|
1020
|
+
// 周期至多一次:夹回后基线 == 活跃量,直到下一轮成功把基线抬高),审计照
|
|
1021
|
+
// auditPeakSkip 的 fail-safe 口径走,记的是「这次收缩把阈值起点拉下来了」。
|
|
1022
|
+
if (count < baseline.count || chars < baseline.chars) {
|
|
1023
|
+
const previous = baseline;
|
|
1024
|
+
baseline = { count: Math.min(baseline.count, count), chars: Math.min(baseline.chars, chars) };
|
|
1025
|
+
try {
|
|
1026
|
+
auditBaselineClamp?.({ previous, count: baseline.count, chars: baseline.chars });
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
logger?.warn?.(`dsh-mneme dream: baseline-clamp audit failed: ${String(error)}`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
998
1031
|
const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
|
|
999
1032
|
const overAbs = count >= thresholdCount || chars >= thresholdChars;
|
|
1000
1033
|
return { trigger: overAbs && overBase, count, chars };
|
|
@@ -1172,6 +1205,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1172
1205
|
// manual review instead of auto-adjudicated. Read once up front so the
|
|
1173
1206
|
// prompt hint and the apply-split agree on the same gate.
|
|
1174
1207
|
const freezeEnabled = config.conflictFreezeEnabled === true;
|
|
1208
|
+
// reflectionUpdateEnabled(默认 true,「关」= 旧记忆永不被自动改写)。同样先读
|
|
1209
|
+
// 一次,prompt 后缀与校验器选项用同一个值,避免两处各读一次 cfg 后彼此不一致。
|
|
1210
|
+
const updateEnabled = config.reflectionUpdateEnabled !== false;
|
|
1175
1211
|
// Every exit (success or failure) funnels through `finish`, which writes
|
|
1176
1212
|
// the audit row + receipt. A record failure is logged, never thrown —
|
|
1177
1213
|
// auditing must not break the consolidation path. Failed runs still
|
|
@@ -1222,14 +1258,16 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1222
1258
|
// input can be rebuilt offline from the audit row alone — the
|
|
1223
1259
|
// digest + decisions + outcome triple makes silent errors locatable
|
|
1224
1260
|
// even after the store has moved on.
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1261
|
+
// #557(dreamInputSummaryEnabled,opt-in 默认关):瘦身只动这里的落库
|
|
1262
|
+
// 副本——LLM 输入在下方由内存快照现算、snapshot_hash 在内存里对含
|
|
1263
|
+
// content 的快照计算,两者都不经过这行,prompt 与 receipt 完整性不受
|
|
1264
|
+
// 影响。organize 轮的重放载荷走 organize.js 自己的 saveDreamRun,
|
|
1265
|
+
// 不经过这里,与 reclaim 的 run_type != 'organize' 同一条边界。
|
|
1266
|
+
input: [...snapshot.values()].map((m) => (
|
|
1267
|
+
config.dreamInputSummaryEnabled === true
|
|
1268
|
+
? { id: m.id, type: m.type, title: m.title, importance: m.importance, updated_at: m.updated_at }
|
|
1269
|
+
: { id: m.id, type: m.type, title: m.title, content: m.content, importance: m.importance, updated_at: m.updated_at }
|
|
1270
|
+
)),
|
|
1233
1271
|
decisions: result.decisions,
|
|
1234
1272
|
outcome: result.outcome,
|
|
1235
1273
|
applied,
|
|
@@ -1264,6 +1302,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1264
1302
|
}
|
|
1265
1303
|
|
|
1266
1304
|
let listText;
|
|
1305
|
+
// Issue #603 第 2 层:把预分组阶段算出的向量相似度留下来,供停车入队时落列。
|
|
1306
|
+
// 这是**本进程内唯一**真实的相似度来源——LLM 裁决给出的只有理由文本,从文本
|
|
1307
|
+
// 里回收数字是猜测。键与 conflict_pending 的对序归一化一致(sort 后的 id 对)。
|
|
1308
|
+
// 拿不到(无向量、跨进程重放、模型自由裁决)就写 NULL,排序按「未知」处理。
|
|
1309
|
+
const pairSimilarity = new Map();
|
|
1310
|
+
const similarityKey = (x, y) => (x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`);
|
|
1267
1311
|
if (semantic?.embedder && semantic?.vectorIndex) {
|
|
1268
1312
|
try {
|
|
1269
1313
|
const vectors = await collectVectors(memories, semantic);
|
|
@@ -1272,6 +1316,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1272
1316
|
const clusters = clusterMemories(memories, vectors, k);
|
|
1273
1317
|
const conflicts = findPotentialConflicts(memories, vectors, 0.85);
|
|
1274
1318
|
const conflictIds = new Set(conflicts.flatMap((c) => [c.a.id, c.b.id]));
|
|
1319
|
+
for (const c of conflicts) pairSimilarity.set(similarityKey(c.a.id, c.b.id), c.similarity);
|
|
1275
1320
|
const parts = [];
|
|
1276
1321
|
clusters.forEach((cluster, ci) => {
|
|
1277
1322
|
parts.push(STR.clusterHeader[language](ci + 1));
|
|
@@ -1301,6 +1346,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1301
1346
|
const consolidationPrompt = freezeEnabled
|
|
1302
1347
|
? STR.prompts.consolidation[language] + STR.prompts.freezeSuffix[language]
|
|
1303
1348
|
: STR.prompts.consolidation[language];
|
|
1349
|
+
// 两个后缀可以叠加:冻结管冲突怎么处理,关闭改写管 update 能不能用,互不相干。
|
|
1350
|
+
const promptWithUpdateGate = updateEnabled
|
|
1351
|
+
? consolidationPrompt
|
|
1352
|
+
: consolidationPrompt + STR.prompts.noUpdateSuffix[language];
|
|
1304
1353
|
let decisionText;
|
|
1305
1354
|
// 加固(v0.8.1):配置的 reasoningEffort 被 provider 拒收时回退重试一次
|
|
1306
1355
|
// (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
|
|
@@ -1335,7 +1384,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1335
1384
|
maxTokens: tokens,
|
|
1336
1385
|
...(withEffort && effort ? { reasoningEffort: effort } : {}),
|
|
1337
1386
|
messages: [
|
|
1338
|
-
{ role: "system", content: [{ type: "text", text:
|
|
1387
|
+
{ role: "system", content: [{ type: "text", text: promptWithUpdateGate }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
1339
1388
|
{ role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
1340
1389
|
]
|
|
1341
1390
|
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
|
|
@@ -1371,6 +1420,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1371
1420
|
}
|
|
1372
1421
|
const { ok, errors, skipped, resolvedShortIds, coverageShortfall } = validateDecisions(decisions, snapshot, {
|
|
1373
1422
|
maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
|
|
1423
|
+
// reflectionUpdateEnabled=false 时校验器单条跳过所有 update(不是整单拒绝)。
|
|
1424
|
+
allowUpdate: updateEnabled,
|
|
1374
1425
|
// Issue #104 方向 2:archive 批量上限(全局闸门,超限整单拒绝)。
|
|
1375
1426
|
maxArchivePerRun: config.dreamMaxArchivePerRun,
|
|
1376
1427
|
// #135 成本审计派生:merge 批量上限(merge 是唯一回吐正文的动作,同款全局闸门)。
|
|
@@ -1458,6 +1509,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1458
1509
|
// bounds the review queue; overflow is skipped with a warning.
|
|
1459
1510
|
let frozenCount = 0;
|
|
1460
1511
|
const frozenIds = [];
|
|
1512
|
+
// Issue #641(enforce 档):T1 supersede 的实质产出计数与向量清理清单。声明在
|
|
1513
|
+
// freeze/enforce 两个分支外层——noChange 判定与 maintainIndexAfterDream 都在
|
|
1514
|
+
// 它们之后、函数作用域里消费。
|
|
1515
|
+
let ledgerApplied = 0;
|
|
1516
|
+
let ledgerVectorFixups = [];
|
|
1461
1517
|
// v0.8.1(issue #170 复核项 3):跨 scope 的 conflict 决策两种模式都一律
|
|
1462
1518
|
// 停车人工裁决、绝不自动归档——否则普通 dream 的 LLM 能归档别的 scope 的
|
|
1463
1519
|
// 唯一副本,sleep 路径(scope-candidate-queue)的保护在这里缺位。同 scope
|
|
@@ -1470,9 +1526,90 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1470
1526
|
|| scopeKeyOf(a.workspace_scope) !== scopeKeyOf(b.workspace_scope)
|
|
1471
1527
|
);
|
|
1472
1528
|
};
|
|
1473
|
-
|
|
1529
|
+
// Issue #641(beta 第二阶段,enforce 档):dream 主链路的 conflict 决策改为
|
|
1530
|
+
// 确定性处置。LLM 判出的 conflict 对先经 triagePair 再分诊:判为 dup 的走
|
|
1531
|
+
// supersedeInfoFirstPair(与 sleep 自动档同一条契约);其余(cross-scope-dup /
|
|
1532
|
+
// evolution / conflict)只落 conflict_ledger 账本(tier + 信号),不停车、
|
|
1533
|
+
// 不归档。#616 的父子绑定不继承:enforce 与 freeze 同开时 freeze 让位——
|
|
1534
|
+
// 本段降级 shadow(conflict 照旧停车),绝不两套处置语义同时生效。
|
|
1535
|
+
const ledgerEnforce = (config.conflictLedgerMode ?? "off") === "enforce" && !freezeEnabled;
|
|
1536
|
+
if ((config.conflictLedgerMode ?? "off") === "enforce" && freezeEnabled) {
|
|
1537
|
+
logger?.warn?.("dsh-mneme dream: conflictLedgerMode=enforce coexists with conflictFreezeEnabled — degrading to freeze; disable one of them");
|
|
1538
|
+
}
|
|
1539
|
+
if (ledgerEnforce) {
|
|
1540
|
+
let t1Resolved = 0;
|
|
1541
|
+
let t3Chained = 0;
|
|
1542
|
+
let marked = 0;
|
|
1543
|
+
for (const d of decisions.filter((x) => x.action === "conflict")) {
|
|
1544
|
+
const a = snapshot.get(d.winner);
|
|
1545
|
+
const b = snapshot.get(d.loser);
|
|
1546
|
+
if (!a || !b) continue;
|
|
1547
|
+
try {
|
|
1548
|
+
// 判据必须与 sleep 侧同源(#650 评审):sleep enforce 传的是
|
|
1549
|
+
// config.conflictLedgerDupMinSimilarity,这里不传就会退回 triage.js 的
|
|
1550
|
+
// 死默认 0.9 —— 用户把阈值调高/调低后,同一对在两条路径上可能分到不同
|
|
1551
|
+
// tier(一侧判 dup 去归档,另一侧判 conflict 只记账)。
|
|
1552
|
+
const verdict = triagePair(a, b, pairSimilarity.get(similarityKey(d.winner, d.loser)), {
|
|
1553
|
+
dupMinSimilarity: config.conflictLedgerDupMinSimilarity
|
|
1554
|
+
});
|
|
1555
|
+
if (verdict.tier === "dup") {
|
|
1556
|
+
const res = supersedeInfoFirstPair({ a, b }, service, logger, config);
|
|
1557
|
+
if (res.applied) {
|
|
1558
|
+
t1Resolved += 1;
|
|
1559
|
+
ledgerApplied += 1;
|
|
1560
|
+
// 败者向量清理走下面的 maintainIndexAfterDream:T1 的 supersede 是
|
|
1561
|
+
// 本 helper 独立落地的,不在 applyList 里,必须显式补进维护清单
|
|
1562
|
+
// (否则索引里留一行已归档记忆的向量,「清得掉才检索得到」失衡)。
|
|
1563
|
+
ledgerVectorFixups.push({ action: "supersede", winner: res.winnerId, loser: res.loserId });
|
|
1564
|
+
// 先落观察行再推进状态(#650 评审):dream 路径不像 sleep 那样先跑
|
|
1565
|
+
// recordLedgerTriage,账本里可能根本没有这一对;此处若直接 UPDATE,
|
|
1566
|
+
// 命中 0 行、changes=0 被静默丢弃,于是「处置成功」与「账本有记录」
|
|
1567
|
+
// 长期背离。upsert 一次拿到确定的行,状态推进才有落点。
|
|
1568
|
+
service.saveConflictLedgerEntry({ run_id: runId, memory_a: a.id, memory_b: b.id, tier: verdict.tier, similarity: verdict.signals.similarity, signals: verdict.signals });
|
|
1569
|
+
service.setConflictLedgerStatus(a.id, b.id, "superseded");
|
|
1570
|
+
try {
|
|
1571
|
+
service.saveConflictReviewed?.({ run_id: runId, memory_a: a.id, memory_b: b.id, outcome: "supersede", judged_at: new Date().toISOString() });
|
|
1572
|
+
} catch (error) {
|
|
1573
|
+
logger?.warn?.(`dsh-mneme dream: conflict_reviewed bookkeeping failed for ${a.id}/${b.id}: ${String(error)}`);
|
|
1574
|
+
}
|
|
1575
|
+
} else {
|
|
1576
|
+
// CAS 没过(两侧在快照后被改过):不冒险自动处置,落账本留观察。
|
|
1577
|
+
service.saveConflictLedgerEntry({ run_id: runId, memory_a: a.id, memory_b: b.id, tier: verdict.tier, similarity: verdict.signals.similarity, signals: verdict.signals });
|
|
1578
|
+
}
|
|
1579
|
+
} else if (verdict.tier === "evolution") {
|
|
1580
|
+
// T3 取代链(与 sleep enforce 同款):旧行 superseded_by → 新行,
|
|
1581
|
+
// 不归档、不进 applyList。快照行可能缺 created_at(老决策),缺了
|
|
1582
|
+
// 就退回纯记账——宁可少处置,不编造时间方向。
|
|
1583
|
+
const newer = (a.created_at ?? "") >= (b.created_at ?? "") ? a : b;
|
|
1584
|
+
const older = newer === a ? b : a;
|
|
1585
|
+
try {
|
|
1586
|
+
if (service.supersedeChain(older.id, newer.id)) {
|
|
1587
|
+
t3Chained += 1;
|
|
1588
|
+
service.saveConflictLedgerEntry({ run_id: runId, memory_a: a.id, memory_b: b.id, tier: verdict.tier, similarity: verdict.signals.similarity, signals: verdict.signals });
|
|
1589
|
+
service.setConflictLedgerStatus(a.id, b.id, "superseded");
|
|
1590
|
+
} else {
|
|
1591
|
+
service.saveConflictLedgerEntry({ run_id: runId, memory_a: a.id, memory_b: b.id, tier: verdict.tier, similarity: verdict.signals.similarity, signals: verdict.signals });
|
|
1592
|
+
}
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
logger?.warn?.(`dsh-mneme dream: ledger T3 chain failed for ${a.id}/${b.id}: ${String(error)}`);
|
|
1595
|
+
}
|
|
1596
|
+
} else {
|
|
1597
|
+
service.saveConflictLedgerEntry({ run_id: runId, memory_a: a.id, memory_b: b.id, tier: verdict.tier, similarity: verdict.signals.similarity, signals: verdict.signals });
|
|
1598
|
+
}
|
|
1599
|
+
marked += 1;
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
logger?.warn?.(`dsh-mneme dream: ledger conflict mark failed for ${d.winner}/${d.loser}: ${String(error)}`);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (marked > 0) {
|
|
1605
|
+
logger?.info?.(`[dsh-mneme] dream: ledger enforce marked ${marked} conflict decision(s), ${t1Resolved} superseded, ${t3Chained} chained`);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
const applyList = ledgerEnforce
|
|
1474
1609
|
? decisions.filter((d) => d.action !== "conflict")
|
|
1475
|
-
:
|
|
1610
|
+
: freezeEnabled
|
|
1611
|
+
? decisions.filter((d) => d.action !== "conflict")
|
|
1612
|
+
: decisions.filter((d) => d.action !== "conflict" || !isCrossScopeDecision(d));
|
|
1476
1613
|
if (freezeEnabled) {
|
|
1477
1614
|
const conflictsToFreeze = decisions.filter((d) => d.action === "conflict");
|
|
1478
1615
|
if (conflictsToFreeze.length > 0) {
|
|
@@ -1488,8 +1625,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1488
1625
|
try {
|
|
1489
1626
|
// 返回 undefined = 该对已被人工裁决且两侧内容未变(issue #170 复核
|
|
1490
1627
|
// 项 4:内容变了才重新入队)——不算停车、不进 frozenIds。
|
|
1491
|
-
const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
|
|
1492
|
-
|
|
1628
|
+
const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason, similarity: pairSimilarity.get(similarityKey(d.winner, d.loser)) });
|
|
1629
|
+
// 只有真的新插了一行才算停车:这一对已在队列里时返回的是旧行
|
|
1630
|
+
// (fresh=false),旧代码把它也算进 frozen,虚报由此而来(#603)。
|
|
1631
|
+
if (parked?.fresh !== true) continue;
|
|
1493
1632
|
frozenCount++;
|
|
1494
1633
|
frozenIds.push(d.winner, d.loser);
|
|
1495
1634
|
} catch (error) {
|
|
@@ -1500,9 +1639,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1500
1639
|
logger?.warn?.(`dsh-mneme dream: conflict freeze lookup failed: ${String(error)}`);
|
|
1501
1640
|
}
|
|
1502
1641
|
}
|
|
1503
|
-
} else {
|
|
1642
|
+
} else if (!ledgerEnforce) {
|
|
1504
1643
|
// 非 freeze:跨 scope conflict 停车(绝不进 applyDecisions)。带专属
|
|
1505
1644
|
// reason + 模型给出的裁决理由,队列里读起来是「归属裁决」而非重复项。
|
|
1645
|
+
// ledger enforce 档不进这里:conflict 决策已在上面落账本(tier 含
|
|
1646
|
+
// cross-scope-dup),零停车零归档(#641)。
|
|
1506
1647
|
const crossScope = decisions.filter((d) => d.action === "conflict" && isCrossScopeDecision(d));
|
|
1507
1648
|
for (const d of crossScope) {
|
|
1508
1649
|
try {
|
|
@@ -1510,9 +1651,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1510
1651
|
run_id: runId,
|
|
1511
1652
|
memory_a: d.winner,
|
|
1512
1653
|
memory_b: d.loser,
|
|
1513
|
-
reason: STR.scopeConflictParkReason[language](d.reason)
|
|
1654
|
+
reason: STR.scopeConflictParkReason[language](d.reason),
|
|
1655
|
+
similarity: pairSimilarity.get(similarityKey(d.winner, d.loser))
|
|
1514
1656
|
});
|
|
1515
|
-
if (parked
|
|
1657
|
+
if (parked?.fresh === true) {
|
|
1516
1658
|
frozenCount++;
|
|
1517
1659
|
frozenIds.push(d.winner, d.loser);
|
|
1518
1660
|
}
|
|
@@ -1574,12 +1716,14 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1574
1716
|
// happened and the scheduler refreshes the baseline on a false positive.
|
|
1575
1717
|
// Frozen conflicts are substantive output (parked for review), so a run
|
|
1576
1718
|
// that only froze conflicts is not a noop.
|
|
1577
|
-
|
|
1719
|
+
// Issue #641:ledger enforce 的 T1 supersede 是独立于 applyList 落地的实质产出,
|
|
1720
|
+
// 计入「本轮有没有干活」,否则有归档产出的轮会被当 noop 刷基线(#634 同族误报)。
|
|
1721
|
+
const noChange = frozenCount === 0 && applied === 0 && ledgerApplied === 0 && committed.every((c) => c.action === "keep");
|
|
1578
1722
|
|
|
1579
1723
|
// Keep the vector index consistent with the post-dream store state.
|
|
1580
1724
|
if (semantic?.embedder && semantic?.vectorIndex) {
|
|
1581
1725
|
try {
|
|
1582
|
-
await maintainIndexAfterDream(applyList, service, semantic);
|
|
1726
|
+
await maintainIndexAfterDream(ledgerVectorFixups.length ? [...applyList, ...ledgerVectorFixups] : applyList, service, semantic);
|
|
1583
1727
|
} catch (error) {
|
|
1584
1728
|
logger?.warn?.(`[dsh-mneme] dream index maintenance failed: ${String(error)}`);
|
|
1585
1729
|
}
|
|
@@ -1714,7 +1858,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1714
1858
|
return finish({
|
|
1715
1859
|
ok: okResult,
|
|
1716
1860
|
status,
|
|
1717
|
-
|
|
1861
|
+
// ledgerApplied 是 ledger enforce 档下 T1 supersede 的独立产出,不在 applyList
|
|
1862
|
+
// 里(#650 评审)。只把它算进 noChange 会让 receipt 与 dream_runs.applied 仍
|
|
1863
|
+
// 报 0:库真被改了,回执说没改——#619 同族假成功。计入 applied 才是唯一口径。
|
|
1864
|
+
applied: applied + ledgerApplied,
|
|
1718
1865
|
decisions: auditDecisions,
|
|
1719
1866
|
outcome,
|
|
1720
1867
|
conflicts,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// In-process call into the mneme HTTP API.
|
|
2
|
+
//
|
|
3
|
+
// The panel runs inside the engine process, exactly where mneme registered its
|
|
4
|
+
// routes. Going through real HTTP would hit the dsh-go bridge guard (401 unless
|
|
5
|
+
// the request carries the process launch token, which is never written to disk),
|
|
6
|
+
// so we dispatch the registered route handler directly and never leave the
|
|
7
|
+
// process. Everything stays on the local web server table: no sockets.
|
|
8
|
+
//
|
|
9
|
+
// Auth is not skipped: mneme's route registrar wraps every route with a policy
|
|
10
|
+
// check, so a write route answers 401 unless the request carries the configured
|
|
11
|
+
// apiToken. We forward it as the x-dsh-mneme-token header (one of the two the
|
|
12
|
+
// registrar accepts) instead of weakening the policy for in-process callers.
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
|
|
15
|
+
const PREFIX = "/api/dsh-mneme";
|
|
16
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
17
|
+
|
|
18
|
+
function makeRequest(url, method, body, apiToken) {
|
|
19
|
+
const req = new EventEmitter();
|
|
20
|
+
req.url = url;
|
|
21
|
+
req.method = method;
|
|
22
|
+
req.headers = {
|
|
23
|
+
host: "127.0.0.1",
|
|
24
|
+
"content-type": "application/json",
|
|
25
|
+
"content-length": String(body === undefined ? 0 : Buffer.byteLength(body)),
|
|
26
|
+
};
|
|
27
|
+
if (typeof apiToken === "string" && apiToken.length > 0) {
|
|
28
|
+
req.headers["x-dsh-mneme-token"] = apiToken;
|
|
29
|
+
}
|
|
30
|
+
req.socket = { remoteAddress: "127.0.0.1", encrypted: false };
|
|
31
|
+
req.resume = () => {};
|
|
32
|
+
req.pause = () => {};
|
|
33
|
+
req.setEncoding = () => {};
|
|
34
|
+
// The handler attaches its body listeners synchronously when invoked, so the
|
|
35
|
+
// body is only released afterwards by flushRequest(); emitting during
|
|
36
|
+
// construction would fire 'data'/'end' with no listener attached and the
|
|
37
|
+
// handler's readBody() would wait forever.
|
|
38
|
+
req.flush = () => {
|
|
39
|
+
queueMicrotask(() => {
|
|
40
|
+
if (body !== undefined) req.emit("data", Buffer.from(body));
|
|
41
|
+
req.emit("end");
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
return req;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeResponse() {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let settled;
|
|
50
|
+
const done = new Promise((resolve) => {
|
|
51
|
+
settled = resolve;
|
|
52
|
+
});
|
|
53
|
+
const headers = {};
|
|
54
|
+
const res = {
|
|
55
|
+
statusCode: 200,
|
|
56
|
+
headersSent: false,
|
|
57
|
+
done,
|
|
58
|
+
writeHead(status, extra) {
|
|
59
|
+
res.statusCode = status;
|
|
60
|
+
res.headersSent = true;
|
|
61
|
+
if (extra !== undefined && extra !== null) {
|
|
62
|
+
for (const key of Object.keys(extra)) headers[key.toLowerCase()] = extra[key];
|
|
63
|
+
}
|
|
64
|
+
return res;
|
|
65
|
+
},
|
|
66
|
+
setHeader(key, value) {
|
|
67
|
+
headers[key.toLowerCase()] = value;
|
|
68
|
+
return res;
|
|
69
|
+
},
|
|
70
|
+
getHeader(key) {
|
|
71
|
+
return headers[key.toLowerCase()];
|
|
72
|
+
},
|
|
73
|
+
removeHeader(key) {
|
|
74
|
+
delete headers[key.toLowerCase()];
|
|
75
|
+
},
|
|
76
|
+
write(chunk) {
|
|
77
|
+
if (chunk !== undefined && chunk !== null && typeof chunk !== "function") {
|
|
78
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
end(chunk) {
|
|
83
|
+
if (chunk !== undefined && chunk !== null && typeof chunk !== "function") {
|
|
84
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
85
|
+
}
|
|
86
|
+
settled();
|
|
87
|
+
return res;
|
|
88
|
+
},
|
|
89
|
+
flushHeaders() {},
|
|
90
|
+
};
|
|
91
|
+
res.text = () => Buffer.concat(chunks).toString("utf8");
|
|
92
|
+
res.bytes = () => Buffer.concat(chunks);
|
|
93
|
+
return res;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 超时定时器不能被 unref():handler 永不响应时,它是唯一还挂在事件循环上的
|
|
97
|
+
// 东西;unref 之后 node 22 的 test runner 会判定循环已空,把用例记成 cancelled
|
|
98
|
+
// (CI 的 node22 两腿就是这么红的)。改成显式 cancel —— 成功路径也不白挂 5 秒。
|
|
99
|
+
function timeout(ms) {
|
|
100
|
+
let timer;
|
|
101
|
+
const promise = new Promise((_, reject) => {
|
|
102
|
+
timer = setTimeout(() => reject(new Error("mneme 接口超时")), ms);
|
|
103
|
+
});
|
|
104
|
+
return { promise, cancel: () => clearTimeout(timer) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the mneme route handler and run it in-process.
|
|
109
|
+
* @param {object} ctx - the plugin context (must expose get()).
|
|
110
|
+
* @param {{apiToken?: string}} [options] - the host's configured API token; the
|
|
111
|
+
* route registrar requires it for every token / token-write route.
|
|
112
|
+
*/
|
|
113
|
+
export function createHostCall(ctx, options = {}) {
|
|
114
|
+
const apiToken = options.apiToken;
|
|
115
|
+
return async function callMneme(path, options = {}) {
|
|
116
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
117
|
+
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
118
|
+
const url = new URL(PREFIX + path, "http://127.0.0.1");
|
|
119
|
+
const webServer = ctx.get?.("webServer") ?? ctx.reflect?.get?.("webServer");
|
|
120
|
+
if (webServer === undefined || typeof webServer.match !== "function") {
|
|
121
|
+
throw new Error("宿主 webServer 不可用");
|
|
122
|
+
}
|
|
123
|
+
const route = webServer.match(url.pathname);
|
|
124
|
+
if (route === undefined || route === null) throw new Error(`mneme 未注册 ${url.pathname}`);
|
|
125
|
+
const req = makeRequest(url.pathname + url.search, method, body, apiToken);
|
|
126
|
+
const res = makeResponse();
|
|
127
|
+
let run;
|
|
128
|
+
try {
|
|
129
|
+
run = Promise.resolve(route.handler(req, res));
|
|
130
|
+
} finally {
|
|
131
|
+
req.flush();
|
|
132
|
+
}
|
|
133
|
+
// Wait for both: most routes return a promise and end the response inside it,
|
|
134
|
+
// but a few (version-check) return undefined and end later from a callback.
|
|
135
|
+
// Racing the three would return the moment the synchronous call returns and
|
|
136
|
+
// hand an empty body to the caller, which then looks like a failed fetch.
|
|
137
|
+
const guard = timeout(options.timeoutMs ?? DEFAULT_TIMEOUT);
|
|
138
|
+
try {
|
|
139
|
+
await Promise.race([
|
|
140
|
+
Promise.all([run, res.done]).then(() => undefined),
|
|
141
|
+
guard.promise,
|
|
142
|
+
]);
|
|
143
|
+
} finally {
|
|
144
|
+
guard.cancel();
|
|
145
|
+
}
|
|
146
|
+
const text = res.text();
|
|
147
|
+
if (res.statusCode >= 400) {
|
|
148
|
+
const detail = text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
|
149
|
+
const error = new Error(`mneme ${method} ${path} → ${res.statusCode} ${detail}`);
|
|
150
|
+
error.status = res.statusCode;
|
|
151
|
+
error.body = text;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
if (options.raw === true) return text;
|
|
155
|
+
if (text === "") return {};
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(text);
|
|
158
|
+
} catch {
|
|
159
|
+
return { text };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|