@modusensus/dsh-mneme 0.8.13 → 0.8.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +655 -626
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +689 -689
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -0
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +263 -0
- package/docs/CONTINUITY.md +124 -0
- package/docs/DAEMON.md +46 -0
- package/docs/ENTITIES.md +245 -0
- package/docs/LOCAL_MODEL.md +222 -0
- package/docs/SEMANTIC.md +256 -0
- package/docs/SLEEP.md +164 -0
- package/docs/STORAGE.md +89 -0
- package/lib/api-standalone.js +673 -572
- package/lib/api.js +1437 -1433
- package/lib/bootstrap.js +211 -211
- package/lib/client.js +5444 -5295
- package/lib/commands.js +95 -95
- package/lib/config.js +880 -836
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +220 -220
- package/lib/document-index.js +94 -94
- package/lib/document.js +303 -303
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +674 -674
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +819 -819
- package/lib/dream.js +1501 -1501
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +252 -252
- package/lib/graph/anchoring.js +179 -179
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +535 -671
- package/lib/inject.js +464 -450
- package/lib/lang.js +248 -229
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +142 -142
- package/lib/mirror.js +330 -330
- package/lib/organize.js +450 -450
- package/lib/peak-hours.js +117 -117
- package/lib/placement.js +13 -0
- package/lib/pre-inject-gate.js +340 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +175 -175
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -204
- package/lib/scope.js +146 -146
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +229 -0
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +215 -0
- package/lib/service.js +2663 -2596
- package/lib/settings.js +517 -509
- package/lib/store.js +3007 -3007
- package/lib/summarize.js +891 -856
- package/lib/tools.js +743 -737
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/lib/write-admission.js +402 -402
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +8 -4
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +36 -0
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +44 -44
- package/src/api-standalone.js +673 -572
- package/src/api.js +1437 -1433
- package/src/bootstrap.js +211 -211
- package/src/commands.js +95 -95
- package/src/config.js +880 -836
- package/src/content-hash.js +54 -54
- package/src/continuity.js +220 -220
- package/src/document-index.js +94 -94
- package/src/document.js +303 -303
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +674 -674
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +819 -819
- package/src/dream.js +1501 -1501
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +252 -252
- package/src/graph/anchoring.js +179 -179
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +46 -46
- package/src/index.js +535 -671
- package/src/inject.js +464 -450
- package/src/lang.js +248 -229
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +142 -142
- package/src/mirror.js +330 -330
- package/src/organize.js +450 -450
- package/src/peak-hours.js +117 -117
- package/src/placement.js +13 -0
- package/src/pre-inject-gate.js +340 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +175 -175
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -204
- package/src/scope.js +146 -146
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +229 -0
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +215 -0
- package/src/service.js +2663 -2596
- package/src/settings.js +517 -509
- package/src/store.js +3007 -3007
- package/src/summarize.js +891 -856
- package/src/tools.js +743 -737
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/src/write-admission.js +402 -402
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1702 -1688
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -220
- package/test/changelog-prep.test.js +60 -0
- package/test/client.test.js +1230 -1169
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +333 -333
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +671 -671
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1385 -1383
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +627 -547
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +175 -175
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +308 -308
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -61
- package/test/inject-parent-gate.test.js +106 -106
- package/test/inject-pools.test.js +194 -194
- package/test/inject-recall.test.js +105 -105
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +420 -420
- package/test/lang.test.js +87 -87
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +598 -598
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +391 -391
- package/test/mcp-stdio.test.js +385 -385
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize.test.js +418 -418
- package/test/peer-blockers.test.js +193 -193
- package/test/placement.test.js +36 -0
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +434 -0
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +189 -189
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -279
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -343
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +207 -207
- package/test/serve-bin.test.js +121 -0
- package/test/serve.test.js +196 -0
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -126
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +527 -527
- package/test/standalone-api.test.js +751 -544
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -396
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -0
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +1465 -1465
- package/test/supersede-state-tracking.test.js +394 -0
- package/test/test-count-sync.test.js +60 -60
- package/test/tools-dto-schema-matrix.test.js +325 -325
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +428 -374
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- package/test/write-admission.test.js +668 -668
package/src/peak-hours.js
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
// 高峰时段(peak hours)解析与判定——纯函数模块,零依赖。
|
|
2
|
-
//
|
|
3
|
-
// 从 summarize.js 抽出(PR #320 review):#316 之后 summarize.js 反向依赖
|
|
4
|
-
// dream.js(withEffortFallback 复用),dream 镜像错峰时再 import summarize
|
|
5
|
-
// 会成真循环。时段调度本来就不是蒸馏的私有语义,独立成模块后两侧都从这里
|
|
6
|
-
// import,循环消失。re-export 兼容留在 summarize.js,调用方零改动。
|
|
7
|
-
//
|
|
8
|
-
// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程
|
|
9
|
-
// 判断不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。
|
|
10
|
-
// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天):
|
|
11
|
-
// "09:00-18:00" 每天 09:00-18:00
|
|
12
|
-
// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun)
|
|
13
|
-
// "sat,sun 23:00-06:00" 周末跨零点段
|
|
14
|
-
// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段,
|
|
15
|
-
// 绝不该因为写错格式把蒸馏/巩固停掉。
|
|
16
|
-
const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
|
|
17
|
-
|
|
18
|
-
function parseDayToken(token) {
|
|
19
|
-
const days = new Set();
|
|
20
|
-
const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
|
|
21
|
-
for (const piece of token.split(",")) {
|
|
22
|
-
const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
|
|
23
|
-
if (!matched) return null;
|
|
24
|
-
const from = normalize(matched[1]);
|
|
25
|
-
const to = matched[2] === undefined ? from : normalize(matched[2]);
|
|
26
|
-
if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
|
|
27
|
-
// 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
|
|
28
|
-
for (let day = from; ; day = (day % 7) + 1) {
|
|
29
|
-
days.add(day);
|
|
30
|
-
if (day === to) break;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
|
|
37
|
-
function isoDay(date) {
|
|
38
|
-
const day = date.getDay();
|
|
39
|
-
return day === 0 ? 7 : day;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function parsePeakSpec(spec) {
|
|
43
|
-
if (typeof spec !== "string" || spec.trim() === "") return null;
|
|
44
|
-
let rest = spec.trim();
|
|
45
|
-
let days = null;
|
|
46
|
-
// 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
|
|
47
|
-
// 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
|
|
48
|
-
// 不做猜测——宁可不省,也不能误停。
|
|
49
|
-
const sep = rest.search(/\s/);
|
|
50
|
-
if (sep > 0) {
|
|
51
|
-
const head = rest.slice(0, sep);
|
|
52
|
-
if (!head.includes(":")) {
|
|
53
|
-
days = parseDayToken(head);
|
|
54
|
-
if (days === null) return null;
|
|
55
|
-
rest = rest.slice(sep).trim();
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const windows = [];
|
|
59
|
-
for (const part of rest.split(",")) {
|
|
60
|
-
const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
|
|
61
|
-
if (!matched) return null;
|
|
62
|
-
const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
|
|
63
|
-
if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
|
|
64
|
-
const start = sh * 60 + sm;
|
|
65
|
-
const end = eh * 60 + em;
|
|
66
|
-
if (start === end) return null;
|
|
67
|
-
windows.push({ start, end });
|
|
68
|
-
}
|
|
69
|
-
return windows.length > 0 ? { days, windows } : null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
|
|
74
|
-
* `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
|
|
75
|
-
*/
|
|
76
|
-
export function isInPeakWindow(date, spec) {
|
|
77
|
-
const parsed = parsePeakSpec(spec);
|
|
78
|
-
if (!parsed) return false;
|
|
79
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
80
|
-
const today = isoDay(date);
|
|
81
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
82
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
83
|
-
return parsed.windows.some(({ start, end }) => {
|
|
84
|
-
if (start < end) return minutes >= start && minutes < end && allowed(today);
|
|
85
|
-
return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
|
|
91
|
-
* 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
|
|
92
|
-
*/
|
|
93
|
-
export function nextOffPeakAt(date, spec) {
|
|
94
|
-
const parsed = parsePeakSpec(spec);
|
|
95
|
-
if (!parsed) return null;
|
|
96
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
97
|
-
const today = isoDay(date);
|
|
98
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
99
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
100
|
-
let bestDelta = null;
|
|
101
|
-
const consider = (delta) => {
|
|
102
|
-
if (bestDelta === null || delta < bestDelta) bestDelta = delta;
|
|
103
|
-
};
|
|
104
|
-
for (const { start, end } of parsed.windows) {
|
|
105
|
-
if (start < end) {
|
|
106
|
-
if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
|
|
110
|
-
else if (minutes < end && allowed(yesterday)) consider(end - minutes);
|
|
111
|
-
}
|
|
112
|
-
if (bestDelta === null) return null;
|
|
113
|
-
const at = new Date(date.getTime());
|
|
114
|
-
at.setSeconds(0, 0);
|
|
115
|
-
at.setMinutes(at.getMinutes() + bestDelta);
|
|
116
|
-
return at;
|
|
117
|
-
}
|
|
1
|
+
// 高峰时段(peak hours)解析与判定——纯函数模块,零依赖。
|
|
2
|
+
//
|
|
3
|
+
// 从 summarize.js 抽出(PR #320 review):#316 之后 summarize.js 反向依赖
|
|
4
|
+
// dream.js(withEffortFallback 复用),dream 镜像错峰时再 import summarize
|
|
5
|
+
// 会成真循环。时段调度本来就不是蒸馏的私有语义,独立成模块后两侧都从这里
|
|
6
|
+
// import,循环消失。re-export 兼容留在 summarize.js,调用方零改动。
|
|
7
|
+
//
|
|
8
|
+
// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程
|
|
9
|
+
// 判断不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。
|
|
10
|
+
// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天):
|
|
11
|
+
// "09:00-18:00" 每天 09:00-18:00
|
|
12
|
+
// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun)
|
|
13
|
+
// "sat,sun 23:00-06:00" 周末跨零点段
|
|
14
|
+
// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段,
|
|
15
|
+
// 绝不该因为写错格式把蒸馏/巩固停掉。
|
|
16
|
+
const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
|
|
17
|
+
|
|
18
|
+
function parseDayToken(token) {
|
|
19
|
+
const days = new Set();
|
|
20
|
+
const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
|
|
21
|
+
for (const piece of token.split(",")) {
|
|
22
|
+
const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
|
|
23
|
+
if (!matched) return null;
|
|
24
|
+
const from = normalize(matched[1]);
|
|
25
|
+
const to = matched[2] === undefined ? from : normalize(matched[2]);
|
|
26
|
+
if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
|
|
27
|
+
// 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
|
|
28
|
+
for (let day = from; ; day = (day % 7) + 1) {
|
|
29
|
+
days.add(day);
|
|
30
|
+
if (day === to) break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
|
|
37
|
+
function isoDay(date) {
|
|
38
|
+
const day = date.getDay();
|
|
39
|
+
return day === 0 ? 7 : day;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parsePeakSpec(spec) {
|
|
43
|
+
if (typeof spec !== "string" || spec.trim() === "") return null;
|
|
44
|
+
let rest = spec.trim();
|
|
45
|
+
let days = null;
|
|
46
|
+
// 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
|
|
47
|
+
// 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
|
|
48
|
+
// 不做猜测——宁可不省,也不能误停。
|
|
49
|
+
const sep = rest.search(/\s/);
|
|
50
|
+
if (sep > 0) {
|
|
51
|
+
const head = rest.slice(0, sep);
|
|
52
|
+
if (!head.includes(":")) {
|
|
53
|
+
days = parseDayToken(head);
|
|
54
|
+
if (days === null) return null;
|
|
55
|
+
rest = rest.slice(sep).trim();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const windows = [];
|
|
59
|
+
for (const part of rest.split(",")) {
|
|
60
|
+
const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
|
|
61
|
+
if (!matched) return null;
|
|
62
|
+
const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
|
|
63
|
+
if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
|
|
64
|
+
const start = sh * 60 + sm;
|
|
65
|
+
const end = eh * 60 + em;
|
|
66
|
+
if (start === end) return null;
|
|
67
|
+
windows.push({ start, end });
|
|
68
|
+
}
|
|
69
|
+
return windows.length > 0 ? { days, windows } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
|
|
74
|
+
* `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
|
|
75
|
+
*/
|
|
76
|
+
export function isInPeakWindow(date, spec) {
|
|
77
|
+
const parsed = parsePeakSpec(spec);
|
|
78
|
+
if (!parsed) return false;
|
|
79
|
+
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
80
|
+
const today = isoDay(date);
|
|
81
|
+
const yesterday = today === 1 ? 7 : today - 1;
|
|
82
|
+
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
83
|
+
return parsed.windows.some(({ start, end }) => {
|
|
84
|
+
if (start < end) return minutes >= start && minutes < end && allowed(today);
|
|
85
|
+
return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
|
|
91
|
+
* 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
|
|
92
|
+
*/
|
|
93
|
+
export function nextOffPeakAt(date, spec) {
|
|
94
|
+
const parsed = parsePeakSpec(spec);
|
|
95
|
+
if (!parsed) return null;
|
|
96
|
+
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
97
|
+
const today = isoDay(date);
|
|
98
|
+
const yesterday = today === 1 ? 7 : today - 1;
|
|
99
|
+
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
100
|
+
let bestDelta = null;
|
|
101
|
+
const consider = (delta) => {
|
|
102
|
+
if (bestDelta === null || delta < bestDelta) bestDelta = delta;
|
|
103
|
+
};
|
|
104
|
+
for (const { start, end } of parsed.windows) {
|
|
105
|
+
if (start < end) {
|
|
106
|
+
if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
|
|
110
|
+
else if (minutes < end && allowed(yesterday)) consider(end - minutes);
|
|
111
|
+
}
|
|
112
|
+
if (bestDelta === null) return null;
|
|
113
|
+
const at = new Date(date.getTime());
|
|
114
|
+
at.setSeconds(0, 0);
|
|
115
|
+
at.setMinutes(at.getMinutes() + bestDelta);
|
|
116
|
+
return at;
|
|
117
|
+
}
|
package/src/placement.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// #368 v1:记忆库落点检测(只检测不搬迁)。
|
|
2
|
+
// 启发式上限:只认显式的 "profiles" 路径段、且其下至少还有一段(作用域目录);
|
|
3
|
+
// 不做「这个目录看起来像用户数据」之类的猜测——漏报可容忍(用户仍可自查文档
|
|
4
|
+
// 的落点建议),误报不可(每次启动都告警等于狼来了)。跨平台:分隔符统一成 /
|
|
5
|
+
// 后按段比较,大小写不敏感(Windows 与大小写不敏感文件系统)。库自身默认
|
|
6
|
+
// ~/.dsh/memory 不含该段,天然不触发。
|
|
7
|
+
// 返回命中的作用域目录名(如 "profiles/demo/memory" 里的 "demo"),未命中返回 null。
|
|
8
|
+
export function findProfileScopeSegment(dir) {
|
|
9
|
+
if (typeof dir !== "string") return null;
|
|
10
|
+
const parts = dir.replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean);
|
|
11
|
+
const i = parts.indexOf("profiles");
|
|
12
|
+
return i !== -1 && i < parts.length - 1 ? parts[i + 1] : null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// Issue #380 注入前判定(preInjectGate):每帧注入候选出池后、进入 system prompt
|
|
2
|
+
// 前,由一次池级 LLM 调用判出「会向本次请求注入意见/立场」的记忆(E12 D1 口径:
|
|
3
|
+
// 判定协议与实验 protocol-preinject.txt 同源;「意见/立场」框架依据 E3 读数 2
|
|
4
|
+
// 「FR 跟随意见内容」,且已被用户采纳为执行期决策)。
|
|
5
|
+
//
|
|
6
|
+
// 两条硬约束决定了本模块的形状:
|
|
7
|
+
// 1. 宿主 systemPrompt 渲染是同步回调(inject.js 的 text callback 不支持 await,
|
|
8
|
+
// service.js Bug4 注释同源)——判定只能 async prefetch + cache、同步消费,与
|
|
9
|
+
// inject.js 的 queryVectorCache 同款。冷缓存首帧降级为不判定原样注入,下一轮
|
|
10
|
+
// 同 key 渲染生效;防线故障永不阻塞注入。
|
|
11
|
+
// 2. 判定只给闸门用,绝不进模型上下文——给模型看任何标记的整条线已被 E2/E3
|
|
12
|
+
// 关闭(E12:判定+标记 D2 比判定+过滤 D1 差 +17.6pp)。
|
|
13
|
+
//
|
|
14
|
+
// 消费点在 service.injectCandidates 内、touchRecalled **之前**(#380 评审:与
|
|
15
|
+
// strictScope 同纪律,见 service.js 的「闸门还必须在 touchRecalled 之前」注释):
|
|
16
|
+
// 滤除若发生在函数返回之后,被滤掉的记忆照样刷温时钟、照样进 recall_runs 曝光账,
|
|
17
|
+
// 反馈环会把闸门想压下的意见记忆重新顶上来。所以判定由 injectCandidates 调用,
|
|
18
|
+
// 帧状态经 gateStats 出参回给调用方做面板快照(不动「返回数组」的既有契约)。
|
|
19
|
+
//
|
|
20
|
+
// 两级语义复用 #254 writeAdmission(enabled 观察档 / enforce 真拦截):
|
|
21
|
+
// enabled=true 才跑判定;enforce=true 且缓存命中时同步滤除被标记候选;
|
|
22
|
+
// observe 档(enforce=false)候选集不动,判定完成时落审计行看真实负载的意见
|
|
23
|
+
// 占比分布(issue:先测分布再决定 enforce)。
|
|
24
|
+
//
|
|
25
|
+
// 审计走 llm_audit_logs:trigger_source="preInjectGate",真实 model_id 与 token
|
|
26
|
+
// 用量(区别于 writeAdmission 的 "skipped"/"-" 占位——本闸有真实 LLM 花费,面板
|
|
27
|
+
// 消费视图自动生效),related_memory_ids=被标记记忆的真实 UUID,metadata 带分布
|
|
28
|
+
// 与降级标记。llmAudit 关闭时本闸不运行(与 write-admission 的 evaluate 早退同
|
|
29
|
+
// 口径:无保留期的表不记账,无账目的判定不可见也不该花钱)。审计行**无条件落**:
|
|
30
|
+
// 无路由时 model_id 落 "unknown"(summarize.js 的无路由占位同款),不许出现
|
|
31
|
+
// 「判定照花钱、账目不见影」(#380 评审)。session_key 不写——store.js 的列语义是
|
|
32
|
+
// 「#254 写入准入的会话键(LLM 调用行恒 NULL)」,写进去会挤占 write-admission
|
|
33
|
+
// 话题回填的 TOPIC_LOOKBACK_ROWS 窗口。
|
|
34
|
+
//
|
|
35
|
+
// 候选映射为 m#<i> 局部序号参与判定、出参映射回真实 UUID——E13b 实测模型抄 36
|
|
36
|
+
// 位 UUID 的错误率足以让整单决策作废,局部短 id 是同源解法。
|
|
37
|
+
//
|
|
38
|
+
// 超时与取消(CodeRabbit on #382):判定挂起用 GenerateOptions.signal(宿主
|
|
39
|
+
// llm.stream 契约自带)真取消底层流,取消发起后**有界等待流真正停止**才放行同
|
|
40
|
+
// key 重试;宿主不理会 signal 时该 key 进冷却期,冷却内不再对该池发新流——宁可
|
|
41
|
+
// 暂缓防线也不累积悬挂流;冷却按 key,跨 key 的悬挂流由熔断兜(不守取消契约与
|
|
42
|
+
// key 无关,同样计入连续降级)。冷却期与宽限期可注入(测试用),默认值不变。
|
|
43
|
+
//
|
|
44
|
+
// 两条资源护栏(#380 评审):
|
|
45
|
+
// - 连续降级熔断:判定器系统性坏掉时(模型持续返回不可解析输出、宿主持续报错)
|
|
46
|
+
// 逐帧重试等于把成本绑到交互帧数上——连续 FAILURE_BREAKER_N 次降级后全局停判
|
|
47
|
+
// 一个冷却期,到期自动恢复。熔断只停花钱,不改防线行为(照旧原样注入)。
|
|
48
|
+
// - 整池被标记时放弃本次 enforce(fail-open):记忆正文是判定模型的可影响输入,
|
|
49
|
+
// 一条指令型记忆可诱导模型标记全池 → 整个记忆块静默失明。该形态本帧退回观察档
|
|
50
|
+
// 原样注入并 warn;判定结果仍在缓存与审计里(n_flagged == n_pool 即此形态)。
|
|
51
|
+
import { STR, langOf } from "./lang.js";
|
|
52
|
+
|
|
53
|
+
export const GATE_TRIGGER_SOURCE = "preInjectGate";
|
|
54
|
+
export const GATE_OPERATION_TYPE = "pre_inject_gate";
|
|
55
|
+
// 判定输出上限:E12 实验同款硬编码(700),池级单次判定足够。
|
|
56
|
+
const JUDGE_MAX_TOKENS = 700;
|
|
57
|
+
// 缓存容量与 inject.js 的 queryVectorCache 同口径(cap 8,命中即刷新序 = LRU:
|
|
58
|
+
// FIFO 下活跃查询会被一次性新池挤出去,之后反复重判)。
|
|
59
|
+
const VERDICT_CACHE_MAX = 8;
|
|
60
|
+
// 流未停止时的 key 冷却期:冷却内不发新流(等旧的死掉/被宿主回收)。
|
|
61
|
+
const DEFAULT_COOLDOWN_MS = 60000;
|
|
62
|
+
// abort 之后的宽限:等底层流停止最多这么久,超过即视为宿主不响应取消。
|
|
63
|
+
const DEFAULT_SETTLE_GRACE_MS = 2000;
|
|
64
|
+
// 连续降级熔断阈值:连坏这么多次就全局停判一个冷却期(见文件头)。
|
|
65
|
+
const FAILURE_BREAKER_N = 3;
|
|
66
|
+
|
|
67
|
+
function extractJson(content) {
|
|
68
|
+
const s = String(content ?? "").trim();
|
|
69
|
+
try { return JSON.parse(s); } catch {}
|
|
70
|
+
const m = s.match(/\{[\s\S]*\}/);
|
|
71
|
+
if (m) { try { return JSON.parse(m[0]); } catch {} }
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createPreInjectGate({ llm, agentDefaultModel, service, config, logger, judgeTimeoutMs, cooldownMs, settleGraceMs } = {}) {
|
|
76
|
+
const language = langOf(config);
|
|
77
|
+
const enabled = config?.preInjectGate?.enabled === true;
|
|
78
|
+
const enforce = config?.preInjectGate?.enforce === true;
|
|
79
|
+
// llmAudit 关闭 → 本闸不运行(见文件头:无账目的判定不可见也不该花钱)。
|
|
80
|
+
const auditDisabled = config?.llmAudit?.enabled === false;
|
|
81
|
+
const usable = enabled && !auditDisabled && typeof llm?.stream === "function";
|
|
82
|
+
if (enabled && !usable) {
|
|
83
|
+
logger?.warn?.("[dsh-mneme] preInjectGate enabled but unusable (llm missing or llmAudit disabled) — running as off");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const verdictCache = new Map(); // cacheKey -> { flaggedIds: Set<uuid>, nPool, reason }
|
|
87
|
+
const inFlight = new Map(); // cacheKey -> Promise(并行渲染去重,防调用风暴)
|
|
88
|
+
const cooldown = new Map(); // cacheKey -> 冷却截止 ms(流未停止的 key,冷却内不发新流)
|
|
89
|
+
const JUDGE_TIMEOUT = judgeTimeoutMs ?? 60000;
|
|
90
|
+
const COOLDOWN_MS = cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
91
|
+
const SETTLE_GRACE_MS = settleGraceMs ?? DEFAULT_SETTLE_GRACE_MS;
|
|
92
|
+
let consecutiveFailures = 0; // 连续降级次数(成功即归零)
|
|
93
|
+
let breakerUntil = 0; // 熔断截止 ms(0 = 未熔断)
|
|
94
|
+
let disposed = false;
|
|
95
|
+
|
|
96
|
+
// cacheKey = 查询 + 排序后的「id:content」:查询变、候选集变、**候选内容变**
|
|
97
|
+
// (updateMemory 就地修正)都换 key——旧判定不沾新内容(CodeRabbit on #382)。
|
|
98
|
+
// 排序保证同池同 key(等价池确定性);内容进 key 换来的是 map 占用略涨,
|
|
99
|
+
// 缓存 cap 8 有界。
|
|
100
|
+
function cacheKey(query, candidates) {
|
|
101
|
+
return `${query}\u0000${candidates.map((c) => `${c.id}:${c.content}`).sort().join("\u0001")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getCached(key) {
|
|
105
|
+
return verdictCache.get(key) ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 渲染帧的唯一同步入口(text callback 内调用,绝不 await)。返回
|
|
109
|
+
// state: "off" 闸门未启用/不可用/已 dispose/空池,调用方原样
|
|
110
|
+
// "filtered" 缓存命中 + enforce:调用方按 flaggedIds 滤除
|
|
111
|
+
// "observed" 缓存命中 + observe(或整池被标记的 enforce 退让):
|
|
112
|
+
// 候选未动,分布已知(flaggedIds 带回)
|
|
113
|
+
// "pending" 冷缓存/冷却中/熔断中:本帧原样渲染;非冷却时判定已在后台发起
|
|
114
|
+
// flaggedIds 只在缓存命中时非空。豁免(pin 池/graphHint 线索行不进判定集)由
|
|
115
|
+
// 调用方在传入 judgedPart 时完成——本模块只认传入的池。
|
|
116
|
+
function forFrame(query, candidates) {
|
|
117
|
+
if (disposed || !usable || !Array.isArray(candidates) || candidates.length === 0) {
|
|
118
|
+
return { state: "off", flaggedIds: null, nPool: 0 };
|
|
119
|
+
}
|
|
120
|
+
const key = cacheKey(query, candidates);
|
|
121
|
+
const cached = verdictCache.get(key);
|
|
122
|
+
if (cached) {
|
|
123
|
+
// 命中刷序(LRU,见 VERDICT_CACHE_MAX 注释)。
|
|
124
|
+
verdictCache.delete(key);
|
|
125
|
+
verdictCache.set(key, cached);
|
|
126
|
+
// enforce 黑屏保护:整池被标记时放弃本次滤除(fail-open,见文件头)。
|
|
127
|
+
// 判定与审计都保留原样,面板上就是 n_flagged == n_pool 这一可见形态。
|
|
128
|
+
const wipeout = enforce && cached.nPool > 0 && cached.flaggedIds.size >= cached.nPool;
|
|
129
|
+
if (wipeout) {
|
|
130
|
+
if (!cached.wipeoutWarned) {
|
|
131
|
+
cached.wipeoutWarned = true;
|
|
132
|
+
logger?.warn?.("[dsh-mneme] preInjectGate flagged the whole pool — skipping enforcement this frame (fail-open)");
|
|
133
|
+
}
|
|
134
|
+
return { state: "observed", flaggedIds: cached.flaggedIds, nPool: cached.nPool };
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
state: enforce ? "filtered" : "observed",
|
|
138
|
+
flaggedIds: cached.flaggedIds,
|
|
139
|
+
nPool: cached.nPool
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// 熔断期:判定器连坏(见文件头),全局停判——本帧原样渲染。
|
|
143
|
+
if (Date.now() < breakerUntil) return { state: "pending", flaggedIds: null, nPool: candidates.length };
|
|
144
|
+
// 冷却中的 key:上一条流还没死,不再叠新流(宁缓防线不积资源)。
|
|
145
|
+
const until = cooldown.get(key);
|
|
146
|
+
if (until) {
|
|
147
|
+
if (Date.now() < until) return { state: "pending", flaggedIds: null, nPool: candidates.length };
|
|
148
|
+
cooldown.delete(key);
|
|
149
|
+
}
|
|
150
|
+
if (inFlight.has(key)) return { state: "pending", flaggedIds: null, nPool: candidates.length };
|
|
151
|
+
prefetch(key, query, candidates);
|
|
152
|
+
return { state: "pending", flaggedIds: null, nPool: candidates.length };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 降级计数:成功即归零(见 prefetch 的 verdict 分支),连坏 FAILURE_BREAKER_N
|
|
156
|
+
// 次就全局停判一个冷却期(见文件头)。宿主不守取消契约(流不停)与判定失败
|
|
157
|
+
// 同属「判定器不可用」,一起计数——否则每个不同查询都能各叠一条悬挂流,冷却
|
|
158
|
+
// 按 key 就兜不住。熔断只停花钱,防线照旧原样注入。
|
|
159
|
+
function noteDegraded() {
|
|
160
|
+
consecutiveFailures += 1;
|
|
161
|
+
if (consecutiveFailures >= FAILURE_BREAKER_N) {
|
|
162
|
+
consecutiveFailures = 0;
|
|
163
|
+
breakerUntil = Date.now() + COOLDOWN_MS;
|
|
164
|
+
logger?.warn?.(`[dsh-mneme] preInjectGate degraded ${FAILURE_BREAKER_N} times in a row — pausing judgment for ${COOLDOWN_MS}ms`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function prefetch(key, query, candidates) {
|
|
169
|
+
if (disposed || verdictCache.has(key) || inFlight.has(key)) return;
|
|
170
|
+
const promise = judgePool(query, candidates)
|
|
171
|
+
.then((outcome) => {
|
|
172
|
+
inFlight.delete(key);
|
|
173
|
+
// dispose 后在飞的结果不回写:拆机之后还改模块状态是 #381 有、#382 丢了的护栏。
|
|
174
|
+
if (disposed) return;
|
|
175
|
+
// 流未停止(宿主不响应 abort):该 key 进冷却,冷却内不发新流。
|
|
176
|
+
if (outcome.timedOut && !outcome.settled) {
|
|
177
|
+
cooldown.set(key, Date.now() + COOLDOWN_MS);
|
|
178
|
+
noteDegraded();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (outcome.verdict) {
|
|
182
|
+
consecutiveFailures = 0;
|
|
183
|
+
verdictCache.set(key, outcome.verdict);
|
|
184
|
+
if (verdictCache.size > VERDICT_CACHE_MAX) {
|
|
185
|
+
verdictCache.delete(verdictCache.keys().next().value);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// 判定失败(verdict=null)不缓存:下一帧重试。但连续降级要熔断——判定器
|
|
190
|
+
// 系统性坏掉时,逐帧重试等于把成本绑到交互帧数上(见文件头)。
|
|
191
|
+
noteDegraded();
|
|
192
|
+
})
|
|
193
|
+
.catch(() => { inFlight.delete(key); });
|
|
194
|
+
inFlight.set(key, promise);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 单次池级判定(E12 口径:1 次调用判整池,不逐条)。返回
|
|
198
|
+
// { verdict, timedOut, settled }:verdict=null 即失败/不可解析/超时(审计行记
|
|
199
|
+
// degraded);timedOut 且 !settled = 底层流未随 abort 停止(调用方进冷却)。
|
|
200
|
+
async function judgePool(query, candidates) {
|
|
201
|
+
let route = {};
|
|
202
|
+
try {
|
|
203
|
+
const sel = agentDefaultModel?.currentSelection?.();
|
|
204
|
+
if (sel?.provider && sel?.model) { route.provider = sel.provider; route.model = sel.model; }
|
|
205
|
+
} catch { /* fall through:无路由照常判定、照常落账(model_id 落 "unknown") */ }
|
|
206
|
+
const modelId = route.provider && route.model ? `${route.provider}:${route.model}` : "";
|
|
207
|
+
const startedAt = Date.now();
|
|
208
|
+
const timestamp = new Date(startedAt).toISOString();
|
|
209
|
+
const source = { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" };
|
|
210
|
+
const messages = [
|
|
211
|
+
{ role: "system", content: [{ type: "text", text: STR.prompts.preInjectGate[language] }], source },
|
|
212
|
+
{ role: "user", content: [{ type: "text", text: JSON.stringify({ query, memories: candidates.map((c, i) => ({ id: `m#${i}`, content: c.content })) }) }], source },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
let text = null;
|
|
216
|
+
let errorMessage = null;
|
|
217
|
+
let inputTokens = 0;
|
|
218
|
+
let outputTokens = 0;
|
|
219
|
+
let timedOut = false;
|
|
220
|
+
let settled = false;
|
|
221
|
+
// 取消协议:GenerateOptions.signal 是宿主 llm.stream 契约自带的取消通道。
|
|
222
|
+
const abort = new AbortController();
|
|
223
|
+
const consume = (async () => {
|
|
224
|
+
try {
|
|
225
|
+
for await (const chunk of llm.stream({ ...route, maxTokens: JUDGE_MAX_TOKENS, messages, signal: abort.signal })) {
|
|
226
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") text = (text ?? "") + chunk.text;
|
|
227
|
+
if (chunk.type === "usage") {
|
|
228
|
+
const u = chunk.usage ?? chunk;
|
|
229
|
+
const i = u.input_tokens ?? u.inputTokens ?? u.prompt_tokens ?? u.promptTokens;
|
|
230
|
+
const o = u.output_tokens ?? u.outputTokens ?? u.completion_tokens ?? u.completionTokens;
|
|
231
|
+
if (Number.isFinite(i)) inputTokens = i;
|
|
232
|
+
if (Number.isFinite(o)) outputTokens = o;
|
|
233
|
+
}
|
|
234
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
235
|
+
errorMessage = "llm stream aborted or errored";
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
settled = true;
|
|
241
|
+
}
|
|
242
|
+
})();
|
|
243
|
+
consume.catch(() => { /* 取消/竞态路径的拒绝已由 race 接过一次,这里防未处理拒绝 */ });
|
|
244
|
+
|
|
245
|
+
let timeoutTimer;
|
|
246
|
+
try {
|
|
247
|
+
await Promise.race([
|
|
248
|
+
consume,
|
|
249
|
+
new Promise((_, reject) => {
|
|
250
|
+
timeoutTimer = setTimeout(() => {
|
|
251
|
+
timedOut = true;
|
|
252
|
+
errorMessage = `judgment timeout (${JUDGE_TIMEOUT}ms)`;
|
|
253
|
+
abort.abort(); // 先取消底层流,再等它停下
|
|
254
|
+
reject(new Error(errorMessage));
|
|
255
|
+
}, JUDGE_TIMEOUT);
|
|
256
|
+
})
|
|
257
|
+
]);
|
|
258
|
+
clearTimeout(timeoutTimer);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
clearTimeout(timeoutTimer);
|
|
261
|
+
if (!timedOut) errorMessage = String(err?.message ?? err);
|
|
262
|
+
logger?.warn?.(`[dsh-mneme] preInjectGate judgment failed: ${errorMessage}`);
|
|
263
|
+
}
|
|
264
|
+
if (timedOut) {
|
|
265
|
+
// 有界等流停止:停了才允许同 key 重试(prefetch 按 settled 决定是否冷却)。
|
|
266
|
+
// 定时器必须显式 clear:流提前停下时它还会把进程多按 SETTLE_GRACE_MS(评审)。
|
|
267
|
+
let graceTimer;
|
|
268
|
+
await Promise.race([
|
|
269
|
+
consume,
|
|
270
|
+
new Promise((r) => { graceTimer = setTimeout(r, SETTLE_GRACE_MS); })
|
|
271
|
+
]).catch(() => {});
|
|
272
|
+
clearTimeout(graceTimer);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 出参解析:m#<i> → 数组索引 → 真实 UUID。越界/重复/非本池 id 一律忽略
|
|
276
|
+
// (解析宽松、应用严格:模型幻觉出的 id 不能滤掉任何真实条目)。
|
|
277
|
+
const parsed = errorMessage ? null : extractJson(text);
|
|
278
|
+
const flaggedIds = new Set();
|
|
279
|
+
let reason = null;
|
|
280
|
+
if (parsed && Array.isArray(parsed.ids)) {
|
|
281
|
+
for (const raw of parsed.ids) {
|
|
282
|
+
const m = /^m#(\d+)$/.exec(String(raw));
|
|
283
|
+
const idx = m ? Number(m[1]) : -1;
|
|
284
|
+
if (idx >= 0 && idx < candidates.length) flaggedIds.add(candidates[idx].id);
|
|
285
|
+
}
|
|
286
|
+
if (typeof parsed.reason === "string" && parsed.reason.trim()) reason = parsed.reason.trim().slice(0, 300);
|
|
287
|
+
} else if (!errorMessage) {
|
|
288
|
+
errorMessage = "unparseable judgment output";
|
|
289
|
+
}
|
|
290
|
+
const degraded = errorMessage !== null || !parsed;
|
|
291
|
+
|
|
292
|
+
// 审计 best-effort(CONTRIBUTING fail-safe 硬约定):写行失败只 warn。
|
|
293
|
+
// llmAudit 关闭时 usable 已为 false,这里理论不可达;防御性再判一次。
|
|
294
|
+
// 条件里**不含 modelId**:判定生效就必有账(无路由落 "unknown" 占位)。
|
|
295
|
+
// session_key 不写(store.js:LLM 调用行恒 NULL;写进去会挤占 write-admission
|
|
296
|
+
// 的话题回填窗口)。
|
|
297
|
+
if (config?.llmAudit?.enabled !== false && typeof service?.saveLlmAudit === "function") {
|
|
298
|
+
try {
|
|
299
|
+
service.saveLlmAudit({
|
|
300
|
+
timestamp,
|
|
301
|
+
trigger_source: GATE_TRIGGER_SOURCE,
|
|
302
|
+
operation_type: GATE_OPERATION_TYPE,
|
|
303
|
+
model_id: modelId || "unknown",
|
|
304
|
+
input_tokens: inputTokens,
|
|
305
|
+
output_tokens: outputTokens,
|
|
306
|
+
total_tokens: inputTokens + outputTokens,
|
|
307
|
+
cost_usd: 0,
|
|
308
|
+
duration_ms: Date.now() - startedAt,
|
|
309
|
+
status: degraded ? "error" : "success",
|
|
310
|
+
error_message: errorMessage,
|
|
311
|
+
related_memory_ids: [...flaggedIds],
|
|
312
|
+
metadata: {
|
|
313
|
+
n_pool: candidates.length,
|
|
314
|
+
n_flagged: flaggedIds.size,
|
|
315
|
+
...(reason ? { reason } : {}),
|
|
316
|
+
degraded
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
} catch (auditError) {
|
|
320
|
+
logger?.warn?.(`[dsh-mneme] preInjectGate audit write failed: ${String(auditError)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (degraded) return { verdict: null, timedOut, settled };
|
|
325
|
+
return { verdict: { flaggedIds, nPool: candidates.length, reason }, timedOut, settled };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// dispose 钩子(index.js 的 disposers)。置 disposed 后:在飞的判定结果不再回写
|
|
329
|
+
// 缓存/冷却,渲染帧直接走 off —— 拆机后模块状态不再变化。
|
|
330
|
+
function clear() {
|
|
331
|
+
disposed = true;
|
|
332
|
+
verdictCache.clear();
|
|
333
|
+
inFlight.clear();
|
|
334
|
+
cooldown.clear();
|
|
335
|
+
consecutiveFailures = 0;
|
|
336
|
+
breakerUntil = 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { enabled: usable, enforce, cacheKey, getCached, forFrame, clear };
|
|
340
|
+
}
|