@modusensus/dsh-mneme 0.8.8 → 0.8.10
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 +4 -4
- package/lib/api-standalone.js +11 -1
- package/lib/api.js +7 -2
- package/lib/client.js +371 -164
- package/lib/config.js +26 -0
- package/lib/continuity.js +1 -1
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +6 -6
- package/lib/entities/extractor.js +2 -2
- package/lib/index.js +10 -4
- package/lib/inject.js +68 -9
- package/lib/peak-hours.js +117 -117
- package/lib/service.js +28 -3
- package/lib/settings.js +9 -1
- package/lib/summarize.js +2 -2
- package/lib/tools.js +17 -8
- package/lib/write-admission.js +165 -22
- package/package.json +1 -1
- package/scripts/benchmark-recall.js +57 -7
- package/src/api-standalone.js +11 -1
- package/src/api.js +7 -2
- package/src/config.js +26 -0
- package/src/continuity.js +1 -1
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +6 -6
- package/src/entities/extractor.js +2 -2
- package/src/index.js +10 -4
- package/src/inject.js +68 -9
- package/src/peak-hours.js +117 -117
- package/src/service.js +28 -3
- package/src/settings.js +9 -1
- package/src/summarize.js +2 -2
- package/src/tools.js +17 -8
- package/src/write-admission.js +165 -22
- package/test/api.test.js +19 -3
- package/test/client.test.js +17 -6
- package/test/continuity.test.js +1 -1
- package/test/epistemic.test.js +27 -0
- package/test/helpers/write-admission-samples.js +308 -0
- package/test/inject.test.js +137 -0
- package/test/llm-message-source.test.js +32 -2
- package/test/recall-anti-update.test.js +82 -0
- package/test/write-admission.test.js +208 -0
package/lib/config.js
CHANGED
|
@@ -71,6 +71,9 @@ export const Config = z.object({
|
|
|
71
71
|
// 提示:上限/原长/全文 memory_get 指引(BUDGET_EXCEEDED 原则——绝不静默)。
|
|
72
72
|
// 默认 300 = 与既有行为一致。
|
|
73
73
|
injectContentMaxChars: z.natural().min(60).max(4000).default(300),
|
|
74
|
+
// Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入一次当前
|
|
75
|
+
// 时间。默认关 = 与既有行为一致;格式与键名不变,存量 feature_flags 恢复即生效。
|
|
76
|
+
injectTimePrefix: z.boolean().default(false),
|
|
74
77
|
importanceThreshold: z.natural().min(1).max(5).default(3),
|
|
75
78
|
// Issue #239(第 5 项):注入条数的查询自适应(默认关)。确定性强的话题收缩注入
|
|
76
79
|
// 条数(减半、下限 1),模糊话题(回指/时间线索,或极短查询)维持
|
|
@@ -588,6 +591,29 @@ export const Config = z.object({
|
|
|
588
591
|
retentionDays: z.natural().min(1).max(3650).default(90)
|
|
589
592
|
}).default({}),
|
|
590
593
|
|
|
594
|
+
// --- write admission (#254) ------------------------------------------------
|
|
595
|
+
// 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工
|
|
596
|
+
// 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档),
|
|
597
|
+
// 这个是**写入前**判定(硬拒不能错)。
|
|
598
|
+
//
|
|
599
|
+
// 两个开关都默认关(验收第 1 条「默认关零行为变化」):
|
|
600
|
+
// enabled — 跑第 1 级确定性判据。关时 evaluate 的返回与只计量那一阶段逐字段
|
|
601
|
+
// 一致,写入路径完全不碰。
|
|
602
|
+
// enforce — 命中时真的拒绝。关时判据照跑、审计照留,决策回落 allow(仅告警)。
|
|
603
|
+
// 读审计时「metadata.deny 非空且 metadata.decision=allow」就是这一档的指纹。
|
|
604
|
+
// 分层是为了能把「先观察」和「开始拦」分成两次上线:enforce 打开前先在真实流量
|
|
605
|
+
// 里看误杀面,而不是靠回滚开关当保险。
|
|
606
|
+
//
|
|
607
|
+
// 第 1 级的判据面刻意收窄到两类:密钥/PII(#164 A2 是判据来源,经
|
|
608
|
+
// createWriteAdmission 的 sensitiveScan 注入;本批只定义它怎么被消费)与空白/
|
|
609
|
+
// 纯噪声文本。去重键命中**不**进第 1 级——按维护者 09-22 的判定归 write-update
|
|
610
|
+
// 放行(2606.24535 的失效模式:同步近重复门跑在异步矛盾检测之前,把本该被裁决
|
|
611
|
+
// 的矛盾直接拒掉);G1/G2 的阈值继续只计量,等遥测有分布再单独提。
|
|
612
|
+
writeAdmission: z.object({
|
|
613
|
+
enabled: z.boolean().default(false),
|
|
614
|
+
enforce: z.boolean().default(false)
|
|
615
|
+
}).default({}),
|
|
616
|
+
|
|
591
617
|
// --- recall evaluation: test-result storage (v0.4.6, 方案 B) --------------
|
|
592
618
|
// Separate retrieval evaluation snapshots from the production recall audit.
|
|
593
619
|
// When false (default) evaluateRetrieval still computes precision/recall/mrr
|
package/lib/continuity.js
CHANGED
|
@@ -205,7 +205,7 @@ export function createContinuityRescue(ctx, store) {
|
|
|
205
205
|
...decision,
|
|
206
206
|
messages: [
|
|
207
207
|
...messages,
|
|
208
|
-
createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin", plugin: "dsh-mneme" } })
|
|
208
|
+
createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })
|
|
209
209
|
]
|
|
210
210
|
};
|
|
211
211
|
});
|
package/lib/dream/sleep.js
CHANGED
|
@@ -334,8 +334,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
334
334
|
maxTokens: config.sleepMaxTokens ?? 2048,
|
|
335
335
|
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
336
336
|
messages: [
|
|
337
|
-
{ role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
338
|
-
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
|
|
337
|
+
{ role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
338
|
+
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
339
339
|
]
|
|
340
340
|
}, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
|
|
341
341
|
};
|
|
@@ -496,8 +496,8 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
496
496
|
maxTokens: config.sleepMaxTokens ?? 2048,
|
|
497
497
|
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
498
498
|
messages: [
|
|
499
|
-
{ role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
500
|
-
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
|
|
499
|
+
{ role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
500
|
+
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
501
501
|
]
|
|
502
502
|
}, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
|
|
503
503
|
};
|
package/lib/dream.js
CHANGED
|
@@ -422,8 +422,8 @@ async function generateNarratives({ ctx, service, config, route, language, effor
|
|
|
422
422
|
maxTokens: config.dreamMaxTokens ?? 2048,
|
|
423
423
|
...(withEffort && effort ? { reasoningEffort: effort } : {}),
|
|
424
424
|
messages: [
|
|
425
|
-
{ role: "system", content: [{ type: "text", text: STR.prompts.narrative[language] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
426
|
-
{ role: "user", content: [{ type: "text", text: listing }], source: { kind: "plugin", plugin: "dsh-mneme" } }
|
|
425
|
+
{ role: "system", content: [{ type: "text", text: STR.prompts.narrative[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
426
|
+
{ role: "user", content: [{ type: "text", text: listing }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
427
427
|
]
|
|
428
428
|
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
|
|
429
429
|
};
|
|
@@ -1100,8 +1100,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1100
1100
|
maxTokens: config.dreamMaxTokens ?? 4096,
|
|
1101
1101
|
...(withEffort && effort ? { reasoningEffort: effort } : {}),
|
|
1102
1102
|
messages: [
|
|
1103
|
-
{ role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
1104
|
-
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
|
|
1103
|
+
{ role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
1104
|
+
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
1105
1105
|
]
|
|
1106
1106
|
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
|
|
1107
1107
|
};
|
|
@@ -1335,8 +1335,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1335
1335
|
maxTokens: config.dreamMaxTokens ?? 2048,
|
|
1336
1336
|
...(withEffort && effort ? { reasoningEffort: effort } : {}),
|
|
1337
1337
|
messages: [
|
|
1338
|
-
{ role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
1339
|
-
{ role: "user", content: [{ type: "text", text: summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin", plugin: "dsh-mneme" } }
|
|
1338
|
+
{ role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
1339
|
+
{ role: "user", content: [{ type: "text", text: summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
1340
1340
|
]
|
|
1341
1341
|
}, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
|
|
1342
1342
|
};
|
|
@@ -150,8 +150,8 @@ export async function extractEntities(memory, { store, config, callLLM, logger }
|
|
|
150
150
|
const userText = buildUserMessage(memory.content);
|
|
151
151
|
|
|
152
152
|
const messages = [
|
|
153
|
-
{ role: "system", content: [{ type: "text", text: systemPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
154
|
-
{ role: "user", content: [{ type: "text", text: userText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
|
|
153
|
+
{ role: "system", content: [{ type: "text", text: systemPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
154
|
+
{ role: "user", content: [{ type: "text", text: userText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
155
155
|
];
|
|
156
156
|
|
|
157
157
|
// Issue #109: optional provider/model override + reasoning effort are
|
package/lib/index.js
CHANGED
|
@@ -272,10 +272,16 @@ export const apply = (ctx, config) => {
|
|
|
272
272
|
documentIndex.remove();
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
275
|
+
// 写入准入实例(#254)。两个开关 writeAdmission.enabled / .enforce 都默认关;
|
|
276
|
+
// 关时它的返回与只计量那一阶段逐字段一致,写入路径完全不碰。既有的
|
|
277
|
+
// llmAudit.enabled 关掉时它同样既不判也不写(那个开关连审计行的启动期清理一起
|
|
278
|
+
// 关掉,在无保留期的表里按写入频次增长是不能接受的)。
|
|
279
|
+
//
|
|
280
|
+
// sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2
|
|
281
|
+
// 的判据来源(A2 记在维护者排期里),所以本批不实现它,只把接口形状定在这里——
|
|
282
|
+
// 接上时只改这一行:
|
|
283
|
+
// createWriteAdmission({ ..., sensitiveScan: createSensitiveScan({ config: cfg }) })
|
|
284
|
+
// 缺省 null 时第 1 级只跑空白 / 噪声两类判据,其余一切照旧。
|
|
279
285
|
const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
|
|
280
286
|
const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
|
|
281
287
|
|
package/lib/inject.js
CHANGED
|
@@ -16,6 +16,23 @@ export function getInjectionSnapshot() {
|
|
|
16
16
|
return injectionSnapshot;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Issue #334:UTF-16 截断切开 emoji 代理对会留下孤立代理项(如 🔴 = D83D DD34
|
|
20
|
+
// 在 800 处切,D83D 留下、DD34 被切走)。DSH 把它序列化成非法 UTF-8,DeepSeek
|
|
21
|
+
// API 对每个请求回 400 INVALID_REQUEST——而且畸形文本随注入消息被永久写进会话
|
|
22
|
+
// 历史,此后**每一轮**都带着它,表现为会话从某一轮起连续 400、换话题/重启都不
|
|
23
|
+
// 恢复(会话级不可逆损坏)。防法是「少一个字符都行,绝不留半个」:尾字符是
|
|
24
|
+
// 高位代理(0xD800–0xDBFF)说明它的低位搭档被切走了,把这个高位代理整个丢掉。
|
|
25
|
+
// (反过来尾字符是低位代理不可能孤立——它的搭档必然紧邻在前、同在切点之内。)
|
|
26
|
+
// ASCII-only 的文本零开销(length 相等直接短路)。
|
|
27
|
+
function safeSlice(text, limit) {
|
|
28
|
+
const s = String(text);
|
|
29
|
+
if (s.length <= limit) return s;
|
|
30
|
+
let cut = s.slice(0, limit);
|
|
31
|
+
const tail = cut.charCodeAt(cut.length - 1);
|
|
32
|
+
if (tail >= 0xd800 && tail <= 0xdbff) cut = cut.slice(0, -1);
|
|
33
|
+
return cut;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
// Best-effort extraction of the current user's latest message text from the
|
|
20
37
|
// live session, for semantic-first injection (Bug4). The system-prompt
|
|
21
38
|
// interpolator renders synchronously, so this walks the already-materialized
|
|
@@ -36,11 +53,13 @@ function lastUserQuery(ctx) {
|
|
|
36
53
|
if (kind !== undefined && kind !== "user") continue;
|
|
37
54
|
const parts = event.data?.content;
|
|
38
55
|
if (!Array.isArray(parts) || parts.length === 0) continue;
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
return safeSlice(
|
|
57
|
+
parts
|
|
58
|
+
.map((p) => (typeof p === "string" ? p : p?.text ?? ""))
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join("\n"),
|
|
61
|
+
500
|
|
62
|
+
);
|
|
44
63
|
}
|
|
45
64
|
} catch { /* session internals unavailable: degrade to no query */ }
|
|
46
65
|
return "";
|
|
@@ -85,7 +104,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
85
104
|
const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
|
|
86
105
|
if (isUser) {
|
|
87
106
|
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
88
|
-
pendingQuery = textOf(event)
|
|
107
|
+
pendingQuery = safeSlice(textOf(event), 500);
|
|
89
108
|
continue;
|
|
90
109
|
}
|
|
91
110
|
// Only assistant-originated events close a round; tool/system events
|
|
@@ -94,7 +113,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
94
113
|
|| kind === "assistant";
|
|
95
114
|
const body = isAssistant ? textOf(event) : "";
|
|
96
115
|
if (!body || !pendingQuery) continue;
|
|
97
|
-
rounds.push({ query: pendingQuery, response: body
|
|
116
|
+
rounds.push({ query: pendingQuery, response: safeSlice(body, 800) });
|
|
98
117
|
pendingQuery = null;
|
|
99
118
|
}
|
|
100
119
|
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
@@ -171,7 +190,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
171
190
|
function injectMemory(m, maxLength = maxContent) {
|
|
172
191
|
const text = String(m?.content ?? "");
|
|
173
192
|
if (text.length <= maxLength) return text;
|
|
174
|
-
return `${text
|
|
193
|
+
return `${safeSlice(text, maxLength)}…${STR.truncatedHint[language](maxLength, text.length, m.id)}`;
|
|
175
194
|
}
|
|
176
195
|
|
|
177
196
|
// Prompt-variable brace escaping (issue #162, restored from v0.7.4 #40):
|
|
@@ -192,6 +211,41 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
192
211
|
return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
|
|
193
212
|
}
|
|
194
213
|
|
|
214
|
+
// Time-prefix injection (issue #34,恢复于 #333): opt-in, off by default.
|
|
215
|
+
// When enabled the current date/time is injected once per conversation — at
|
|
216
|
+
// the first prompt assembly of a new session — so the model can sense what
|
|
217
|
+
// time/day it is. A per-session latch means later assemblies in the same
|
|
218
|
+
// session never re-inject; a bare render ctx (no session id) falls back to
|
|
219
|
+
// once per injector lifetime.
|
|
220
|
+
// v0.7.11 面板大改版(ce4658e)时这段随一次批量删除被静默移除,CHANGELOG 无
|
|
221
|
+
// 登记(#162 的 escapePromptVars 同刀误删、后来恢复,本函数漏了)——用户按
|
|
222
|
+
// CHANGELOG 开着开关,实际什么都不会发生。本次按 v0.7.2 语义原样恢复:键名、
|
|
223
|
+
// 默认值(false)、格式 `[当前时间: YYYY-MM-DD 周X HH:MM]` 全部不变,存量
|
|
224
|
+
// feature_flags 里的配置恢复即生效。
|
|
225
|
+
// 已注入过时间前缀的会话集合(#335 review:单值闩锁在 A→B→A 交替渲染下会
|
|
226
|
+
// 重复注入)。上限 500 防长驻进程无界增长,超限逐出最早记录。
|
|
227
|
+
const timePrefixDone = new Set();
|
|
228
|
+
let timePrefixBare = false;
|
|
229
|
+
|
|
230
|
+
function renderTimePrefix(ctx) {
|
|
231
|
+
if (config.injectTimePrefix !== true) return "";
|
|
232
|
+
const sessionId = ctx?.agent?.session?.id;
|
|
233
|
+
if (sessionId === undefined) {
|
|
234
|
+
if (timePrefixBare) return "";
|
|
235
|
+
timePrefixBare = true;
|
|
236
|
+
} else {
|
|
237
|
+
if (timePrefixDone.has(sessionId)) return "";
|
|
238
|
+
if (timePrefixDone.size >= 500) timePrefixDone.delete(timePrefixDone.keys().next().value);
|
|
239
|
+
timePrefixDone.add(sessionId);
|
|
240
|
+
}
|
|
241
|
+
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
|
242
|
+
const now = new Date();
|
|
243
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
244
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
245
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
246
|
+
return escapePromptVars(`[当前时间: ${date} ${WEEKDAYS[now.getDay()]} ${time}]`);
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
// Hot memory (v0.5.0 1.3): the latest rounds of THIS session, rebuilt from
|
|
196
250
|
// the materialized event log on every render — stateless, so it survives
|
|
197
251
|
// session switches and never persists anywhere.
|
|
@@ -335,7 +389,12 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
335
389
|
// separate context) keeps the prompt assembly stable at two blocks.
|
|
336
390
|
const hotText = renderHotContext(ctx);
|
|
337
391
|
const body = render(candidates, pinnedStats);
|
|
338
|
-
|
|
392
|
+
// #333(issue #34 恢复):时间前缀只在会话首轮出现(per-session 闩锁),
|
|
393
|
+
// 排在整个记忆块的最前——模型先知道「今天几号」,再看热上下文与长期记忆。
|
|
394
|
+
const timePrefix = renderTimePrefix(ctx);
|
|
395
|
+
// 拼接次序:时间 → 热上下文 → 长期记忆(三者都可能为空,逐级判空拼接)。
|
|
396
|
+
const withHot = !hotText ? body : body ? `${hotText}\n\n${body}` : hotText;
|
|
397
|
+
const finalBody = !timePrefix ? withHot : withHot ? `${timePrefix}\n\n${withHot}` : timePrefix;
|
|
339
398
|
// Issue #179:旁路缓存一帧——面板「注入预览」卡据此展示构成与体积。
|
|
340
399
|
// chars 为条目内容的截断后近似值(与 render 同一函数计长),totalChars
|
|
341
400
|
// 是本次实际返回块的精确长度。maxItems 反映自适应收缩后的生效值。
|
package/lib/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/lib/service.js
CHANGED
|
@@ -1250,9 +1250,12 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1250
1250
|
scheduleEmbed(result);
|
|
1251
1251
|
return { action: "merged", memory: result };
|
|
1252
1252
|
}
|
|
1253
|
-
// #254
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1253
|
+
// #254 写入准入:决策形状由 write-admission.js 一次定死(阶段一定死、之后只加
|
|
1254
|
+
// 分支),这里只做两件事——先问决策,再在 enforce 命中时**在 store.save 之前**
|
|
1255
|
+
// 返回。放在 store.save 之前是这条闸门的意义所在:写进去再删等于没拦,而且中间
|
|
1256
|
+
// 那一瞬的注入/检索面已经暴露了。
|
|
1257
|
+
// 计量是旁路,拒绝不是:evaluate 抛异常按「不判定」处理(宁可漏拦也不能让判据
|
|
1258
|
+
// 故障把写入变成不可用),record 抛异常同样只 warn(审计失败不能反噬写入)。
|
|
1256
1259
|
let admission = null;
|
|
1257
1260
|
if (writeAdmission) {
|
|
1258
1261
|
try {
|
|
@@ -1261,6 +1264,23 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1261
1264
|
try { logger?.warn?.(`[dsh-mneme] write admission evaluate failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1262
1265
|
}
|
|
1263
1266
|
}
|
|
1267
|
+
// 第 1 级硬拒(enforce 打开且判据命中):这次写入不落库、不通知、不排嵌入、
|
|
1268
|
+
// 不抽实体。审计行仍然要落——拒绝面是这条闸门唯一可解释性的来源(验收第 2 条
|
|
1269
|
+
// 「被拒写入有审计与明确原因、可解释不静默丢弃」)。memoryId 传 null:这次写入
|
|
1270
|
+
// 没有产生任何行,related_memory_ids 必须空着,挂一个不存在的 id 就是造虚账。
|
|
1271
|
+
// reason 随返回值透出,工具层据此给模型一句可行动的拒绝理由。
|
|
1272
|
+
if (admission?.decision === "deny") {
|
|
1273
|
+
try {
|
|
1274
|
+
writeAdmission.record({
|
|
1275
|
+
sessionKey: memory._sessionKey,
|
|
1276
|
+
verdict: admission,
|
|
1277
|
+
memoryId: null
|
|
1278
|
+
});
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
try { logger?.warn?.(`[dsh-mneme] write admission record failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1281
|
+
}
|
|
1282
|
+
return { action: "denied", reason: admission.deny?.reason ?? "denied", deny: admission.deny ?? null, memory: null };
|
|
1283
|
+
}
|
|
1264
1284
|
const created = store.save({
|
|
1265
1285
|
type: memory.type,
|
|
1266
1286
|
title: memory.title,
|
|
@@ -1268,6 +1288,11 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1268
1288
|
tags: memory.tags ?? [],
|
|
1269
1289
|
importance: memory.importance ?? 3,
|
|
1270
1290
|
source: memory.source ?? "manual",
|
|
1291
|
+
// epistemic_status 必须显式透传:省略时 store 会回退到内容标记推断
|
|
1292
|
+
// (中文正则),英文/无标记内容一律落 subjective——E2 四臂实验当场抓到
|
|
1293
|
+
// (trustEpistemicWeighting 的重排对 saveWithDedupe 写入的记忆整体空转,
|
|
1294
|
+
// R+ 臂与 A 臂注入序逐条相同)。undefined 保持推断行为,显式值优先。
|
|
1295
|
+
epistemic_status: memory.epistemic_status,
|
|
1271
1296
|
...(memory.evidence !== undefined ? { evidence: memory.evidence } : {}),
|
|
1272
1297
|
// v0.8.0 A1:scope 标注透传(store 端归一化,未标注落 NULL)。
|
|
1273
1298
|
// v0.8.1 底座:来源(auto/explicit)与决策时间随行透传。
|
package/lib/settings.js
CHANGED
|
@@ -43,6 +43,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
43
43
|
"autoInject",
|
|
44
44
|
"autoSummarize",
|
|
45
45
|
"hotMemoryEnabled",
|
|
46
|
+
// Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入当前时间。
|
|
47
|
+
// 顶层扁平键,随白名单回面板(FEATURE_GROUPS 的 group.core)。
|
|
48
|
+
"injectTimePrefix",
|
|
46
49
|
// Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
|
|
47
50
|
"injectUncertaintyAdaptive",
|
|
48
51
|
// Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
|
|
@@ -100,7 +103,12 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
100
103
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
101
104
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
102
105
|
"memoryQualityFilter.enabled",
|
|
103
|
-
"llmAudit.enabled"
|
|
106
|
+
"llmAudit.enabled",
|
|
107
|
+
// Issue #254:写入准入(第 1 级确定性拒绝)。两个键分层——enabled 跑判据、
|
|
108
|
+
// enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回
|
|
109
|
+
// writeAdmission 对象。
|
|
110
|
+
"writeAdmission.enabled",
|
|
111
|
+
"writeAdmission.enforce"
|
|
104
112
|
];
|
|
105
113
|
// 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
|
|
106
114
|
const FEATURE_FLAG_INT_RANGES = {
|
package/lib/summarize.js
CHANGED
|
@@ -220,7 +220,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
220
220
|
}, undefined);
|
|
221
221
|
return {
|
|
222
222
|
messages: lines.length
|
|
223
|
-
? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin", plugin: "dsh-mneme" } })]
|
|
223
|
+
? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })]
|
|
224
224
|
: [],
|
|
225
225
|
hasEvents: events.length > 0,
|
|
226
226
|
lastSeq
|
|
@@ -495,7 +495,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
495
495
|
model: route.model,
|
|
496
496
|
purpose: "summarization",
|
|
497
497
|
messages: [
|
|
498
|
-
{ role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
498
|
+
{ role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
499
499
|
...messages
|
|
500
500
|
],
|
|
501
501
|
signal: controller.signal
|