@modusensus/dsh-mneme 0.8.7 → 0.8.9
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 +6 -6
- package/lib/api.js +1 -1
- package/lib/client.js +406 -158
- package/lib/config.js +65 -2
- package/lib/continuity.js +220 -0
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +144 -45
- package/lib/entities/extractor.js +2 -2
- package/lib/guide.js +18 -0
- package/lib/index.js +37 -1
- package/lib/peak-hours.js +117 -0
- package/lib/settings.js +14 -1
- package/lib/store.js +87 -0
- package/lib/summarize.js +54 -115
- package/lib/tools.js +11 -3
- package/package.json +1 -1
- package/src/api.js +1 -1
- package/src/config.js +65 -2
- package/src/continuity.js +220 -0
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +144 -45
- package/src/entities/extractor.js +2 -2
- package/src/guide.js +18 -0
- package/src/index.js +37 -1
- package/src/peak-hours.js +117 -0
- package/src/settings.js +14 -1
- package/src/store.js +87 -0
- package/src/summarize.js +54 -115
- package/src/tools.js +11 -3
- package/test/api.test.js +11 -4
- package/test/client.test.js +30 -7
- package/test/continuity.test.js +333 -0
- package/test/dream-failure-backoff.test.js +173 -0
- package/test/dream-peak-hours.test.js +194 -0
- package/test/llm-message-source.test.js +32 -2
- package/test/reasoning-effort.test.js +6 -0
- package/test/summarize-reasoning-effort.test.js +189 -0
package/src/guide.js
CHANGED
|
@@ -42,3 +42,21 @@ export const TOOL_GUIDE = {
|
|
|
42
42
|
" Save only durable, cross-session value (a preference, a decision with its rationale, an engineering constraint, " +
|
|
43
43
|
"a pitfall with its root cause). Trivial single-turn work does not belong here, and when unsure, do not save."
|
|
44
44
|
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* #249 N3 的降级路径:宿主若不提供可挂钩的压缩前时机,双落点里的「注入」那一半就没
|
|
48
|
+
* 有触发者。此时不把功能算作失败,而是把规则交给 agent 自判压力(§4.4 点名的降级
|
|
49
|
+
* 形态)。落点是工具描述——常驻文本、不进每轮上下文,零注入成本。
|
|
50
|
+
*
|
|
51
|
+
* 英文单一正本,理由同 TOOL_GUIDE(见文件头)。字段名用存储与注入那一侧的口径
|
|
52
|
+
* (`current_work` / `next_step` / open question,#249 §6.2);规格 §4.4 里的
|
|
53
|
+
* Current Work / Next Step / Critical Context 是**宿主压缩模板**的字段名,不是这里的。
|
|
54
|
+
*/
|
|
55
|
+
export const CONTINUITY_TOOL_RULE = {
|
|
56
|
+
memory_save:
|
|
57
|
+
" If this session's context is about to be compacted, first save a continuity note with this same tool " +
|
|
58
|
+
"(type: project, title: one stable title for this line of work, e.g. \"continuity: <topic>\"): current_work (what you " +
|
|
59
|
+
"are doing now), next_step, and any open question. Keep it to those fields, and reuse that same title for later " +
|
|
60
|
+
"updates: the store merges rows of the same type + title (the merge appends to the note instead of replacing it), " +
|
|
61
|
+
"so a fresh title each time just leaves a trail of near-duplicate project rows."
|
|
62
|
+
};
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createService } from "./service.js";
|
|
|
7
7
|
import { createWriteAdmission } from "./write-admission.js";
|
|
8
8
|
import { createTools } from "./tools.js";
|
|
9
9
|
import { createInjector } from "./inject.js";
|
|
10
|
+
import { createContinuityRescue } from "./continuity.js";
|
|
10
11
|
import { createSummarizer } from "./summarize.js";
|
|
11
12
|
import { createDreamScheduler } from "./dream.js";
|
|
12
13
|
import { createSleepScheduler, runSleep } from "./dream/sleep.js";
|
|
@@ -20,7 +21,7 @@ import { createEmbedder } from "./embedding.js";
|
|
|
20
21
|
import { createEmbedderByProvider } from "./local-embedder.js";
|
|
21
22
|
import { LocalReranker } from "./reranker.js";
|
|
22
23
|
import { createVectorIndex } from "./vector-index.js";
|
|
23
|
-
import { Config, applyLightModePreset } from "./config.js";
|
|
24
|
+
import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
|
|
24
25
|
import { langOf } from "./lang.js";
|
|
25
26
|
import { extractEntities } from "./entities/extractor.js";
|
|
26
27
|
import { mkdirSync } from "node:fs";
|
|
@@ -490,9 +491,37 @@ export const apply = (ctx, config) => {
|
|
|
490
491
|
thresholdChars: cfg.dreamThresholdChars,
|
|
491
492
|
delayMs: cfg.dreamDelayMs,
|
|
492
493
|
minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
|
|
494
|
+
// Issue #292:连续失败指数退避(opt-in,默认关 = 行为不变)。
|
|
495
|
+
failureBackoff: cfg.autoDreamFailureBackoff === true,
|
|
493
496
|
logger: ctx.logger,
|
|
494
497
|
semantic: { embedder, vectorIndex },
|
|
495
498
|
lastRunAtSeed: store.lastDreamRunAt("auto"),
|
|
499
|
+
// Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。
|
|
500
|
+
peakHours: cfg.dreamPeakHours ?? "",
|
|
501
|
+
peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120,
|
|
502
|
+
// 跳过时的审计行在这里落地(调度器只拿到 service,拿不到 config 的
|
|
503
|
+
// llmAudit 开关与巩固模型路由)。口径与 runAuditedLlm 一致:审计关掉就
|
|
504
|
+
// 不写;写失败只 warn,绝不反噬调度(CONTRIBUTING 的 fail-safe 硬约定)。
|
|
505
|
+
auditPeakSkip: ({ count, chars }) => {
|
|
506
|
+
if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
|
|
507
|
+
const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : "";
|
|
508
|
+
service.saveLlmAudit({
|
|
509
|
+
timestamp: new Date().toISOString(),
|
|
510
|
+
trigger_source: "autoDream",
|
|
511
|
+
operation_type: "dream_consolidate",
|
|
512
|
+
model_id: modelId,
|
|
513
|
+
input_tokens: 0,
|
|
514
|
+
output_tokens: 0,
|
|
515
|
+
total_tokens: 0,
|
|
516
|
+
cost_usd: 0,
|
|
517
|
+
duration_ms: 0,
|
|
518
|
+
status: "skipped",
|
|
519
|
+
error_message: "peak-hours",
|
|
520
|
+
related_memory_ids: [],
|
|
521
|
+
// 观测用:跳过时窗口里积了多少(阈值继续累积,不是丢弃)。
|
|
522
|
+
metadata: JSON.stringify({ count, chars })
|
|
523
|
+
});
|
|
524
|
+
},
|
|
496
525
|
onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
|
|
497
526
|
});
|
|
498
527
|
service.setDreamHook(() => dream.maybeSchedule(service));
|
|
@@ -559,6 +588,13 @@ export const apply = (ctx, config) => {
|
|
|
559
588
|
if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg));
|
|
560
589
|
});
|
|
561
590
|
|
|
591
|
+
// #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
|
|
592
|
+
// 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子
|
|
593
|
+
// 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。
|
|
594
|
+
if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) {
|
|
595
|
+
disposers.push(createContinuityRescue(ctx, store));
|
|
596
|
+
}
|
|
597
|
+
|
|
562
598
|
ctx.inject(["tools"], (toolsCtx) => {
|
|
563
599
|
disposers.push(createTools(toolsCtx, service, cfg, embedder));
|
|
564
600
|
});
|
|
@@ -0,0 +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
|
+
}
|
package/src/settings.js
CHANGED
|
@@ -48,6 +48,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
48
48
|
// Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
|
|
49
49
|
// 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。
|
|
50
50
|
"injectGuidanceEnabled",
|
|
51
|
+
// Issue #249 N3:压缩边缘双落点——上下文即将精简前落一条连续性提案 + 往
|
|
52
|
+
// 序列末尾追加同一份快照。默认关(新注入表面,见 config.js),父开关 autoInject。
|
|
53
|
+
"continuityRescueEnabled",
|
|
51
54
|
"entityExtractionEnabled",
|
|
52
55
|
// Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
|
|
53
56
|
// (默认关;依赖实体抽取产出,lightMode 强制关闭)。
|
|
@@ -60,6 +63,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
60
63
|
"documentMemoryEnabled",
|
|
61
64
|
"codingRetrospect",
|
|
62
65
|
"autoDream",
|
|
66
|
+
// Issue #292:autoDream 连续失败退避(默认关)。基数是 dreamMinIntervalMinutes,
|
|
67
|
+
// 有效间隔 = 基数 × 2^连续失败数(成功清零),封顶 30 分钟;面板可启停。
|
|
68
|
+
"autoDreamFailureBackoff",
|
|
63
69
|
"sleepModeEnabled",
|
|
64
70
|
"heatEnabled",
|
|
65
71
|
"hybridInject",
|
|
@@ -114,6 +120,8 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
114
120
|
summarizeMaxRunsPerSession: [0, 1000],
|
|
115
121
|
// Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
|
|
116
122
|
summarizePeakMaxDeferMinutes: [0, 1440],
|
|
123
|
+
// Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
|
|
124
|
+
dreamPeakMaxDeferMinutes: [0, 1440],
|
|
117
125
|
// Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
|
|
118
126
|
dreamCandidateMax: [0, 5000],
|
|
119
127
|
// Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。
|
|
@@ -156,7 +164,9 @@ const FEATURE_FLAG_STRINGS = [
|
|
|
156
164
|
"localEmbedModel",
|
|
157
165
|
"ollamaModel",
|
|
158
166
|
// Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。
|
|
159
|
-
"summarizePeakHours"
|
|
167
|
+
"summarizePeakHours",
|
|
168
|
+
// Issue #239 第 4 项镜像到巩固:同一份时段语法,空串 = 关闭(行为与现状一致)。
|
|
169
|
+
"dreamPeakHours"
|
|
160
170
|
];
|
|
161
171
|
// URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议,
|
|
162
172
|
// 拒绝其余协议——这是 SSRF 防线的一部分)。
|
|
@@ -169,6 +179,9 @@ const FEATURE_FLAG_ENUMS = {
|
|
|
169
179
|
recallFusion: ["blend", "rrf", "minmax"],
|
|
170
180
|
// 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
|
|
171
181
|
entityExtractionReasoning: ["low", "medium", "high", "none"],
|
|
182
|
+
// 蒸馏思考强度(issue #315):与 entityExtractionReasoning 枚举对齐,
|
|
183
|
+
// 多一个 off(显式关思考,思考型模型蒸馏防推理烧预算)。
|
|
184
|
+
summarizeReasoningEffort: ["off", "low", "medium", "high", "none"],
|
|
172
185
|
// Issue #127:落库前去重档位(off 默认,等同现状)。
|
|
173
186
|
summarizeDedupeMode: ["off", "title", "vector"],
|
|
174
187
|
// Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
|
package/src/store.js
CHANGED
|
@@ -276,6 +276,28 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
276
276
|
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
|
|
277
277
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
278
278
|
);
|
|
279
|
+
|
|
280
|
+
-- #249 N3(压缩边缘双落点):连续性提案。压缩边缘只落**提案**行,不进 memories
|
|
281
|
+
-- ——边缘产出若直接进记忆库,一个长会话就会攒出第 N 条同主题条目,正是 #275 记的
|
|
282
|
+
-- 失败形态;转正通道与 #254 的二次确认共用一套,阶段二才打开,本批只写 pending。
|
|
283
|
+
-- 唯一键 (session_id, kind) 就是形态约定里的「同一会话同一类只留一条」:再次触发
|
|
284
|
+
-- 是刷新同一行,不是新增一行。status 为转正通道预留(pending → promoted/discarded):
|
|
285
|
+
-- 「实际触发率」= 提案行数、「采纳率」= promoted/总数,都能就地统计(#249 §8)。
|
|
286
|
+
-- 时间戳是 ISO(与 memories 同款),created_at 在刷新时不动,updated_at 记最近边缘。
|
|
287
|
+
CREATE TABLE IF NOT EXISTS continuity_proposals (
|
|
288
|
+
id TEXT PRIMARY KEY,
|
|
289
|
+
session_id TEXT NOT NULL,
|
|
290
|
+
kind TEXT NOT NULL,
|
|
291
|
+
current_work TEXT,
|
|
292
|
+
next_step TEXT,
|
|
293
|
+
open_questions TEXT,
|
|
294
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
295
|
+
edge_seq INTEGER, -- 触发本次刷新的压缩事件 seq(证据/复现用)
|
|
296
|
+
created_at TEXT NOT NULL,
|
|
297
|
+
updated_at TEXT NOT NULL
|
|
298
|
+
);
|
|
299
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_continuity_session_kind ON continuity_proposals(session_id, kind);
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_continuity_status ON continuity_proposals(status, updated_at);
|
|
279
301
|
`;
|
|
280
302
|
|
|
281
303
|
// Exported for API-layer type validation (standalone API POST /memories and
|
|
@@ -286,6 +308,12 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s
|
|
|
286
308
|
// (registerDocument)——saveWithDedupe/updateMemory 另有守卫拒绝旁路铸造。
|
|
287
309
|
"document"]);
|
|
288
310
|
|
|
311
|
+
// #249 N3:连续性提案的 pending 队列上限。「队列满则弃新」是形态约定的一半——满时丢
|
|
312
|
+
// 掉本次触发,**不是**淘汰旧行:旧行是别的会话还没转正的活状态,用"更近的边缘"把它挤
|
|
313
|
+
// 掉,等于让长会话的噪音吃掉短会话的真实工作状态。这个数是存储侧策略,与抽取/注入的
|
|
314
|
+
// 截断上限(continuity.js)是两件事。
|
|
315
|
+
const MAX_CONTINUITY_PENDING = 200;
|
|
316
|
+
|
|
289
317
|
// Epistemic status: what kind of evidence a memory rests on. Defaults to
|
|
290
318
|
// 'subjective' so legacy rows (and rows without any signal) stay compatible.
|
|
291
319
|
const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
|
|
@@ -2645,6 +2673,60 @@ export function createStore(path) {
|
|
|
2645
2673
|
* Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
|
|
2646
2674
|
* bump commit together: a crash between them can never leave a mutated store
|
|
2647
2675
|
* with generation == applied (audit peer blocker 1, "crash window"). */
|
|
2676
|
+
// --- #249 N3:压缩边缘的连续性提案(唯一写入口 = 压缩边缘监听器) -------------
|
|
2677
|
+
/** Upsert one continuity proposal. (session_id, kind) is the形态约定「同一会话同一类
|
|
2678
|
+
* 只留一条」:命中即刷新(created_at 不动、updated_at 记最近边缘),不新增行。
|
|
2679
|
+
* Queue full → drop the new one and say so (dropped: true) instead of throwing: an edge
|
|
2680
|
+
* that cannot be recorded must not break the host's step. Returns {id, created, dropped}. */
|
|
2681
|
+
function saveContinuityProposal({
|
|
2682
|
+
sessionId, kind, currentWork = null, nextStep = null, openQuestions = null, edgeSeq = null,
|
|
2683
|
+
maxPending = MAX_CONTINUITY_PENDING
|
|
2684
|
+
} = {}) {
|
|
2685
|
+
if (!sessionId || !kind) throw new TypeError("continuity proposal needs sessionId and kind");
|
|
2686
|
+
const now = new Date().toISOString();
|
|
2687
|
+
const existing = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind);
|
|
2688
|
+
if (!existing) {
|
|
2689
|
+
const pending = db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = 'pending'").get().c;
|
|
2690
|
+
if (pending >= maxPending) return { id: null, created: false, dropped: true };
|
|
2691
|
+
}
|
|
2692
|
+
// 写入本身是一条 UPSERT:唯一索引 (session_id, kind) 是并发的裁决者。两个宿主共用
|
|
2693
|
+
// memoryDir 时(AGENTS.md 的多进程 WAL 场景)先查后插会有一个撞 SQLITE_CONSTRAINT,
|
|
2694
|
+
// 而这里只该有一条语句决定成不成功。上面的 SELECT 只用于「队列满」与 created 标记。
|
|
2695
|
+
// created_at 与 status 刻意不在 SET 里:命中只刷新内容与最近边缘时刻,不把已转正的行打回 pending。
|
|
2696
|
+
const id = randomUUID();
|
|
2697
|
+
db.prepare(`
|
|
2698
|
+
INSERT INTO continuity_proposals (id, session_id, kind, current_work, next_step, open_questions, status, edge_seq, created_at, updated_at)
|
|
2699
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
|
|
2700
|
+
ON CONFLICT(session_id, kind) DO UPDATE SET
|
|
2701
|
+
current_work = excluded.current_work,
|
|
2702
|
+
next_step = excluded.next_step,
|
|
2703
|
+
open_questions = excluded.open_questions,
|
|
2704
|
+
edge_seq = excluded.edge_seq,
|
|
2705
|
+
updated_at = excluded.updated_at
|
|
2706
|
+
`).run(id, sessionId, kind, currentWork, nextStep, openQuestions, edgeSeq, now, now);
|
|
2707
|
+
const row = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind);
|
|
2708
|
+
return { id: row?.id ?? id, created: !existing, dropped: false };
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
/** The single proposal for (session, kind), or null. */
|
|
2712
|
+
function getContinuityProposal(sessionId, kind) {
|
|
2713
|
+
return db.prepare("SELECT * FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind) ?? null;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
/** Newest-first proposals, optionally filtered by status (转正通道的读侧预置)。 */
|
|
2717
|
+
function listContinuityProposals({ status, limit = 50 } = {}) {
|
|
2718
|
+
const bounded = Number.isSafeInteger(limit) && limit > 0 ? limit : 50;
|
|
2719
|
+
return status
|
|
2720
|
+
? db.prepare("SELECT * FROM continuity_proposals WHERE status = ? ORDER BY updated_at DESC, id LIMIT ?").all(status, bounded)
|
|
2721
|
+
: db.prepare("SELECT * FROM continuity_proposals ORDER BY updated_at DESC, id LIMIT ?").all(bounded);
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
function countContinuityProposals({ status } = {}) {
|
|
2725
|
+
return status
|
|
2726
|
+
? db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = ?").get(status).c
|
|
2727
|
+
: db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals").get().c;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2648
2730
|
function runAtomically(fn) {
|
|
2649
2731
|
if (db.isTransaction) return fn();
|
|
2650
2732
|
db.exec("BEGIN");
|
|
@@ -2769,6 +2851,11 @@ export function createStore(path) {
|
|
|
2769
2851
|
setTypeStatus,
|
|
2770
2852
|
getTypeStatus,
|
|
2771
2853
|
incrementGeneration,
|
|
2854
|
+
// #249 N3:压缩边缘的连续性提案(落提案、转正才进 memories)。
|
|
2855
|
+
saveContinuityProposal,
|
|
2856
|
+
getContinuityProposal,
|
|
2857
|
+
listContinuityProposals,
|
|
2858
|
+
countContinuityProposals,
|
|
2772
2859
|
close() {
|
|
2773
2860
|
db.close();
|
|
2774
2861
|
}
|
package/src/summarize.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
2
|
import { STR, langOf } from "./lang.js";
|
|
3
|
+
// Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
|
|
4
|
+
// 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
|
|
5
|
+
// 三条链路同一降级语义,不另造第二份实现。
|
|
6
|
+
import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
|
|
3
7
|
|
|
4
8
|
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
|
|
5
9
|
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
|
|
@@ -216,7 +220,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
216
220
|
}, undefined);
|
|
217
221
|
return {
|
|
218
222
|
messages: lines.length
|
|
219
|
-
? [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" } })]
|
|
220
224
|
: [],
|
|
221
225
|
hasEvents: events.length > 0,
|
|
222
226
|
lastSeq
|
|
@@ -256,117 +260,14 @@ function enqueueDistill(task, intervalMs = 0) {
|
|
|
256
260
|
return run;
|
|
257
261
|
}
|
|
258
262
|
|
|
259
|
-
// Issue #239(第 4
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
|
|
268
|
-
|
|
269
|
-
function parseDayToken(token) {
|
|
270
|
-
const days = new Set();
|
|
271
|
-
const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
|
|
272
|
-
for (const piece of token.split(",")) {
|
|
273
|
-
const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
|
|
274
|
-
if (!matched) return null;
|
|
275
|
-
const from = normalize(matched[1]);
|
|
276
|
-
const to = matched[2] === undefined ? from : normalize(matched[2]);
|
|
277
|
-
if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
|
|
278
|
-
// 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
|
|
279
|
-
for (let day = from; ; day = (day % 7) + 1) {
|
|
280
|
-
days.add(day);
|
|
281
|
-
if (day === to) break;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
|
|
288
|
-
function isoDay(date) {
|
|
289
|
-
const day = date.getDay();
|
|
290
|
-
return day === 0 ? 7 : day;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function parsePeakSpec(spec) {
|
|
294
|
-
if (typeof spec !== "string" || spec.trim() === "") return null;
|
|
295
|
-
let rest = spec.trim();
|
|
296
|
-
let days = null;
|
|
297
|
-
// 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
|
|
298
|
-
// 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
|
|
299
|
-
// 不做猜测——宁可不省,也不能误停。
|
|
300
|
-
const sep = rest.search(/\s/);
|
|
301
|
-
if (sep > 0) {
|
|
302
|
-
const head = rest.slice(0, sep);
|
|
303
|
-
if (!head.includes(":")) {
|
|
304
|
-
days = parseDayToken(head);
|
|
305
|
-
if (days === null) return null;
|
|
306
|
-
rest = rest.slice(sep).trim();
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
const windows = [];
|
|
310
|
-
for (const part of rest.split(",")) {
|
|
311
|
-
const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
|
|
312
|
-
if (!matched) return null;
|
|
313
|
-
const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
|
|
314
|
-
if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
|
|
315
|
-
const start = sh * 60 + sm;
|
|
316
|
-
const end = eh * 60 + em;
|
|
317
|
-
if (start === end) return null;
|
|
318
|
-
windows.push({ start, end });
|
|
319
|
-
}
|
|
320
|
-
return windows.length > 0 ? { days, windows } : null;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
|
|
325
|
-
* `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
|
|
326
|
-
*/
|
|
327
|
-
export function isInPeakWindow(date, spec) {
|
|
328
|
-
const parsed = parsePeakSpec(spec);
|
|
329
|
-
if (!parsed) return false;
|
|
330
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
331
|
-
const today = isoDay(date);
|
|
332
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
333
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
334
|
-
return parsed.windows.some(({ start, end }) => {
|
|
335
|
-
if (start < end) return minutes >= start && minutes < end && allowed(today);
|
|
336
|
-
return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
|
|
342
|
-
* 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
|
|
343
|
-
*/
|
|
344
|
-
export function nextOffPeakAt(date, spec) {
|
|
345
|
-
const parsed = parsePeakSpec(spec);
|
|
346
|
-
if (!parsed) return null;
|
|
347
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
348
|
-
const today = isoDay(date);
|
|
349
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
350
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
351
|
-
let bestDelta = null;
|
|
352
|
-
const consider = (delta) => {
|
|
353
|
-
if (bestDelta === null || delta < bestDelta) bestDelta = delta;
|
|
354
|
-
};
|
|
355
|
-
for (const { start, end } of parsed.windows) {
|
|
356
|
-
if (start < end) {
|
|
357
|
-
if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
|
|
361
|
-
else if (minutes < end && allowed(yesterday)) consider(end - minutes);
|
|
362
|
-
}
|
|
363
|
-
if (bestDelta === null) return null;
|
|
364
|
-
const at = new Date(date.getTime());
|
|
365
|
-
at.setSeconds(0, 0);
|
|
366
|
-
at.setMinutes(at.getMinutes() + bestDelta);
|
|
367
|
-
return at;
|
|
368
|
-
}
|
|
369
|
-
|
|
263
|
+
// Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块
|
|
264
|
+
// src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像
|
|
265
|
+
// 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export
|
|
266
|
+
// 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。
|
|
267
|
+
// 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数,
|
|
268
|
+
// import + re-export 两行都要。
|
|
269
|
+
import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
|
|
270
|
+
export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
|
|
370
271
|
export function createSummarizer(ctx, service, config, deps = {}) {
|
|
371
272
|
if (!config.autoSummarize) return { dispose: () => {} };
|
|
372
273
|
|
|
@@ -584,12 +485,17 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
584
485
|
};
|
|
585
486
|
}
|
|
586
487
|
|
|
488
|
+
// Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
|
|
489
|
+
// 行为与历史版本一致);off/low/medium/high 原样传递。
|
|
490
|
+
const summarizeEffort = config.summarizeReasoningEffort;
|
|
491
|
+
const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
|
|
492
|
+
|
|
587
493
|
const options = {
|
|
588
494
|
provider: route.provider,
|
|
589
495
|
model: route.model,
|
|
590
496
|
purpose: "summarization",
|
|
591
497
|
messages: [
|
|
592
|
-
{ 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" } },
|
|
593
499
|
...messages
|
|
594
500
|
],
|
|
595
501
|
signal: controller.signal
|
|
@@ -597,15 +503,30 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
597
503
|
// 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
|
|
598
504
|
// 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
|
|
599
505
|
const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
|
|
600
|
-
|
|
506
|
+
// Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
|
|
507
|
+
// 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
|
|
508
|
+
// runNarratives 的 streamFailure 同款模式)。
|
|
509
|
+
let streamFailure = "";
|
|
510
|
+
const runDistill = (withEffort) => {
|
|
511
|
+
streamFailure = "";
|
|
512
|
+
return enqueueDistill(async () => {
|
|
601
513
|
const retries = config.distillRateLimitRetries ?? 3;
|
|
602
514
|
const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
|
|
515
|
+
// 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
|
|
516
|
+
// 不带 reasoningEffort,不能复用带字段的同一 options 对象。
|
|
517
|
+
const callOptions = withEffort && summarizeEffort
|
|
518
|
+
? { ...options, reasoningEffort: summarizeEffort }
|
|
519
|
+
: options;
|
|
603
520
|
for (let attempt = 0; ; attempt++) {
|
|
521
|
+
// 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
|
|
522
|
+
// error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
|
|
523
|
+
// (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
|
|
524
|
+
if (audit) audit.status = "success";
|
|
604
525
|
const assembler = new BlockAssembler();
|
|
605
526
|
let text = "";
|
|
606
527
|
let aborted = false;
|
|
607
528
|
try {
|
|
608
|
-
for await (const chunk of ctx.llm.stream(
|
|
529
|
+
for await (const chunk of ctx.llm.stream(callOptions)) {
|
|
609
530
|
if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
|
|
610
531
|
if (chunk.type === "text-delta") {
|
|
611
532
|
text += chunk.text ?? chunk.delta ?? "";
|
|
@@ -627,6 +548,9 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
627
548
|
if (isRateLimited(chunk.reason ?? chunk)) {
|
|
628
549
|
throw Object.assign(new Error("rate limited"), { status: 429 });
|
|
629
550
|
}
|
|
551
|
+
// Issue #315:把失败原因原样留给外层的 effort 甄别
|
|
552
|
+
// (withEffortFallback 只认「effort 被拒收」型失败)。
|
|
553
|
+
streamFailure = describeStreamFailure(chunk.reason ?? chunk);
|
|
630
554
|
if (audit) {
|
|
631
555
|
audit.status = "error";
|
|
632
556
|
audit.errorMessage = `llm stream ${reasonKind}`;
|
|
@@ -666,6 +590,21 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
666
590
|
}
|
|
667
591
|
}
|
|
668
592
|
}, intervalMs);
|
|
593
|
+
};
|
|
594
|
+
// Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
|
|
595
|
+
// 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
|
|
596
|
+
// 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
|
|
597
|
+
// undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
|
|
598
|
+
// 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
|
|
599
|
+
const result = await withEffortFallback(
|
|
600
|
+
ctx,
|
|
601
|
+
summarizeEffort,
|
|
602
|
+
() => runDistill(withEffort).then((r) =>
|
|
603
|
+
(r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
|
|
604
|
+
() => runDistill(false),
|
|
605
|
+
() => streamFailure
|
|
606
|
+
);
|
|
607
|
+
const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
|
|
669
608
|
if (aborted) {
|
|
670
609
|
abortedRun = true;
|
|
671
610
|
return;
|
package/src/tools.js
CHANGED
|
@@ -5,7 +5,7 @@ import { defaultRuntimeDir } from "./runtime/layout.js";
|
|
|
5
5
|
import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
|
|
6
6
|
import { matchesPlatform } from "./runtime/closure.js";
|
|
7
7
|
import { verifyPayload } from "./runtime/verify.js";
|
|
8
|
-
import { TOOL_GUIDE } from "./guide.js";
|
|
8
|
+
import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
|
|
9
9
|
import { injectChildEnabled } from "./config.js";
|
|
10
10
|
|
|
11
11
|
const TEXT_OUTPUT = (text) => [{ type: "text", text }];
|
|
@@ -90,8 +90,16 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
90
90
|
// 关闭时描述逐字节不变,也不动其他工具的文案。
|
|
91
91
|
// 它是注入父开关的子项(#249 第二批):`autoInject` 关掉时这里同样不生效——
|
|
92
92
|
// 工具描述是这个子项唯一住在注入器之外的落点,不在闸门内就漏了。
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// #249 N3 降级路径(continuityRescueEnabled):宿主若不提供可挂钩的压缩前时机,
|
|
94
|
+
// 双落点里的注入那一半就没有触发者,这时把规则交给 agent 自判(见 CONTINUITY_TOOL_RULE)。
|
|
95
|
+
// 与能力说明各自独立:任一开关关闭时描述逐字节不变。
|
|
96
|
+
// 本批不做宿主能力探测(没有可靠的探测口),所以子开关打开时这条规则常驻,与自动双落点并存。
|
|
97
|
+
const withToolGuide = (name, description) => {
|
|
98
|
+
let text = description;
|
|
99
|
+
if (injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name]) text += TOOL_GUIDE[name];
|
|
100
|
+
if (injectChildEnabled(config, "continuityRescueEnabled") && CONTINUITY_TOOL_RULE[name]) text += CONTINUITY_TOOL_RULE[name];
|
|
101
|
+
return text;
|
|
102
|
+
};
|
|
95
103
|
|
|
96
104
|
// 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按
|
|
97
105
|
// 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope
|