@jayyuen66/dsh-lesson-loop 0.0.0-stage → 0.1.0
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 -0
- package/README.md +215 -2
- package/client.js +920 -0
- package/cordis.patch.yml +12 -0
- package/host.js +2687 -0
- package/icon.svg +1 -0
- package/package.json +106 -3
package/host.js
ADDED
|
@@ -0,0 +1,2687 @@
|
|
|
1
|
+
// GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
4
|
+
import { dshCachePath } from "@deepseek-ai/dsh-home-paths";
|
|
5
|
+
import { brandString } from "@deepseek-ai/dsh-brand";
|
|
6
|
+
import { guardBody, queryParam, sendJson } from "@jayyuen66/dsh-plugin-shared/lib/http";
|
|
7
|
+
import { guardTrust } from "@jayyuen66/dsh-plugin-shared/lib/trust";
|
|
8
|
+
import { errorText } from "@jayyuen66/dsh-plugin-shared/lib/errors";
|
|
9
|
+
import { deriveProjectKey } from "@jayyuen66/dsh-plugin-shared/lib/project-key";
|
|
10
|
+
import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { shrinkJsonlTail } from "@jayyuen66/dsh-plugin-shared/lib/jsonl";
|
|
14
|
+
import { LOCALE_SETTINGS_NAMESPACE, messagesFor, resolveLocalePreference } from "@jayyuen66/dsh-plugin-shared/lib/locale";
|
|
15
|
+
//#region lib/jsonl-fuse.ts
|
|
16
|
+
/** 超限时按行对半收缩(判据在 shared/lib/jsonl.ts,与 ctx-observe 同源;单行超限保末行兜底)。
|
|
17
|
+
* 错误出口留在这里吞掉:本包的承诺是"落库失败只日志、不抛穿宿主热路径"。 */
|
|
18
|
+
function trimJsonl(file, maxBytes) {
|
|
19
|
+
try {
|
|
20
|
+
if (statSync(file).size <= maxBytes) return;
|
|
21
|
+
const text = readFileSync(file, "utf8");
|
|
22
|
+
const shrunk = shrinkJsonlTail(text, maxBytes);
|
|
23
|
+
if (shrunk !== text) writeFileSync(file, shrunk);
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region lib/lesson-jsonl.ts
|
|
28
|
+
/** JSONL 单行容错解析:坏行/非对象 → null(供 readJsonl 计 bad)。 */
|
|
29
|
+
function parseJsonLine(line) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(line);
|
|
32
|
+
return isRecord(parsed) ? parsed : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** JSONL 追加(容错:路径/建目录/截断/追加任一失败只日志不抛——热路径不许炸宿主)。 */
|
|
38
|
+
function appendJsonl(file, row, maxBytes) {
|
|
39
|
+
try {
|
|
40
|
+
const dir = path.dirname(file);
|
|
41
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
42
|
+
if (maxBytes > 0) trimJsonl(file, maxBytes);
|
|
43
|
+
appendFileSync(file, `${JSON.stringify(row)}\n`);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error(`[lesson-loop] lesson append failed: ${errorText(error)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 读整个 JSONL(容错;坏行跳过并 warn 一次计数)。 */
|
|
49
|
+
function readJsonl(file) {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(file)) return [];
|
|
52
|
+
const text = readFileSync(file, "utf8");
|
|
53
|
+
const out = [];
|
|
54
|
+
let bad = 0;
|
|
55
|
+
for (const line of text.split("\n")) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (trimmed.length > 0) {
|
|
58
|
+
const parsed = parseJsonLine(trimmed);
|
|
59
|
+
if (parsed === null) bad += 1;
|
|
60
|
+
else out.push(parsed);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (bad > 0) console.warn(`[lesson-loop] ${bad} malformed lesson line(s) skipped in ${file}`);
|
|
64
|
+
return out;
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region lib/rule-signature.ts
|
|
71
|
+
/** 规则归并键。签名归一:trim + 内部空白折叠(大小写保留——路径/命令大小写有意)。 */
|
|
72
|
+
function normalizeSignature(signature) {
|
|
73
|
+
if (typeof signature !== "string") return "";
|
|
74
|
+
return signature.trim().replaceAll(/\s+/gu, " ");
|
|
75
|
+
}
|
|
76
|
+
const CATEGORY_SIGNATURES = {
|
|
77
|
+
"factgate-deny": "edit-before-factgate",
|
|
78
|
+
"secret-path": "edit-before-secret-path"
|
|
79
|
+
};
|
|
80
|
+
/** 查表用 Map:Object 字面量的索引会带出原型链上的同名成员(`toString`/
|
|
81
|
+
* `constructor` 当 category 时拿到的是函数),Map 没有这个洞。 */
|
|
82
|
+
const STABLE_SIGNATURES = new Map(Object.entries(CATEGORY_SIGNATURES));
|
|
83
|
+
/**
|
|
84
|
+
* 签名归一(写入侧唯一入口):登记表内的类别一律折叠到类别级稳定签名,表外类别
|
|
85
|
+
* 保留自身签名(行为与折叠前逐字节相同)。幂等——稳定签名再查一次仍是自身,
|
|
86
|
+
* 所以存量卡与新建卡走同一个键。report()/pass()/addCandidate() 与卡片查找必须
|
|
87
|
+
* 全部经这里,任何一处漏调都会造成"写得进、查不到"的键漂移。
|
|
88
|
+
*/
|
|
89
|
+
function stableSignatureFor(category, signature) {
|
|
90
|
+
const normalized = normalizeSignature(signature);
|
|
91
|
+
return STABLE_SIGNATURES.get(category) ?? normalized;
|
|
92
|
+
}
|
|
93
|
+
/** 路径/文件名形态签名判定(迁移归并的识别器)。 */
|
|
94
|
+
function looksLikePathSignature(signature) {
|
|
95
|
+
return signature.includes("/") || signature.includes("\\") || /^\.{1,2}[/\\]/u.test(signature) || /\.(?:tsx?|jsx?|mjs|cjs|rs|py|go|java|kt|swift|vue|svelte|json|ya?ml|css|scss|html|md|sh|sql|toml|c|cpp|h|hpp|lock)$/iu.test(signature);
|
|
96
|
+
}
|
|
97
|
+
function ruleKey(project, category, signature) {
|
|
98
|
+
return `${project}\u0000${category}\u0000${normalizeSignature(signature)}`;
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region lib/messages.ts
|
|
102
|
+
/**
|
|
103
|
+
* host 半的整行模板插值:`{name}` 取参数,取不到的占位符**原样留着**
|
|
104
|
+
* (少传一个参数是编程错误,不该让文案里凭空多出空白;官方 client locale 同语义)。
|
|
105
|
+
* @param template - 字典里的整行模板。
|
|
106
|
+
* @param params - 占位符对应的值。
|
|
107
|
+
* @returns 插值完成的文本。
|
|
108
|
+
*/
|
|
109
|
+
function fill(template, params) {
|
|
110
|
+
return template.replaceAll(/\{(?<name>\w+)\}/gu, (matched, name) => {
|
|
111
|
+
const value = params[name];
|
|
112
|
+
return value === void 0 ? matched : String(value);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const MESSAGES = {
|
|
116
|
+
zh: {
|
|
117
|
+
sectionText: [
|
|
118
|
+
"## lesson-loop 自进化环",
|
|
119
|
+
"本环境运行教训闭环:危险操作拦截、门禁失败、异常续跑、人工差评等事件会自动沉淀为项目教训,达到阈值的教训蒸馏为候选规则,经用户在设置卡确认后成为经验规则(armed),并在会话开始注入。",
|
|
120
|
+
"- 被守卫拒绝时:按拒绝信息补齐证据后重试,不要换措辞绕过;拒绝本身是可学习信号。",
|
|
121
|
+
"- 会话开始若注入了\"经验规则\",视为高优先级约束,执行中主动遵守。",
|
|
122
|
+
"- 用户可用 /lessons-digest 把本会话人工差评即时蒸馏为教训。"
|
|
123
|
+
].join("\n"),
|
|
124
|
+
digestHeading: "[lesson-loop] 本项目经验规则(人工确认生效;执行中请主动遵守,违反会被度量统计):",
|
|
125
|
+
digestRuleLine: "{index}. {statement}({day} 生效{metrics})",
|
|
126
|
+
armedMetrics: ",armed 后复发 {violation} 次 / 被遵守 {suppressed} 次 / 暴露 {samples} 次",
|
|
127
|
+
armedNoTrigger: ",armed 后在场 {samples} 次会话但未触发(尚无可判定证据)",
|
|
128
|
+
statusCandidate: "待确认",
|
|
129
|
+
statusArmed: "生效中",
|
|
130
|
+
statusDemoted: "已降级待人审",
|
|
131
|
+
statusRejected: "已拒绝",
|
|
132
|
+
statusArchived: "已归档",
|
|
133
|
+
ruleSummary: "[{label}]{statement}({category} · {signature} · 观察 {occurrences} 次{metrics})",
|
|
134
|
+
ruleSummaryArmed: " · 复发 {violation} / 被遵守 {suppressed} / 暴露 {samples}",
|
|
135
|
+
statementFactgateStable: "编辑文件之前,必须先成功 read 该文件,并做项目级引用检索(grep/glob/zg_search 覆盖引用面)确认改动波及面(调用方/依赖方/测试),再动手。",
|
|
136
|
+
statementFactgateRejected: "已拒绝的历史路径碎片归档汇总({signature}):编辑文件前先取证的要求由上方生效规则承载;本条仅保留拒绝记录与证据,不注入、不度量。",
|
|
137
|
+
statementFactgatePath: "编辑 {signature} 之前,必须先成功 read 该文件,并做项目级引用检索(grep/glob/zg_search 覆盖引用面),按证据档位补齐 test/e2e/docs 层探查后再动手。",
|
|
138
|
+
statementDangerousBash: "避免使用危险 shell 形态({signature})。需要同类操作时先向用户说明风险或改用安全替代(如去掉 --no-verify、先确认再删除、用包管理器替代 curl|sh)。",
|
|
139
|
+
statementSecretPathStable: "不要编辑密钥/凭据类文件(.env、*.key、token 配置等);确需变更时先征得用户明确同意,并最小化接触面。",
|
|
140
|
+
statementSecretPathTarget: "不要编辑密钥/凭据类文件({signature});确需变更时先征得用户明确同意,并最小化接触面。",
|
|
141
|
+
statementGateFailure: "结束回合前先本地运行 {signature} 自检并修完再收口;该门禁在此项目已多次失败,不要假设\"应该能过\"。",
|
|
142
|
+
statementTransientFailure: "{signature} 出现过瞬时失败(限流/服务端/超时)。遇到同类失败保持冷静等待自动续跑,不要丢弃已有进度重做。",
|
|
143
|
+
statementMaxTokens: "长输出任务主动分段交付,避免单回合输出撞 max-tokens 上限被截断。",
|
|
144
|
+
statementUnfinishedTurn: "回合结束前把任务清单收口:完成一项勾一项,不要带着未闭合的待办结束回合。",
|
|
145
|
+
statementGeneric: "避免重复以下问题:{signature}(详见证据)。",
|
|
146
|
+
digestSystemRole: "你是工程质量教练。输入是一次会话的人工差评与该项目的历史教训,请把差评归纳为可执行的候选规则。",
|
|
147
|
+
digestSystemShape: "输出严格为 JSON 数组,不要输出任何其他文字。数组每项形如:",
|
|
148
|
+
digestSystemShapeSignature: "同类问题的稳定短键,如命令/路径/场景名",
|
|
149
|
+
digestSystemShapeStatement: "规则正文:具体、可执行、可判定,说明该怎么做而不是只说不要做",
|
|
150
|
+
digestSystemRules: "要求:每条规则必须是\"下次怎么避免\"的可执行指令;差评之间同因合并;没有差评信号时输出 [];不编造差评里没有的事实。",
|
|
151
|
+
digestSectionFeedback: "## 本会话人工差评",
|
|
152
|
+
digestSectionBackground: "## 该项目历史教训(背景,防止重复提出已存在的规则)",
|
|
153
|
+
digestSectionExtra: "## 用户附加说明",
|
|
154
|
+
digestNoFeedback: "(无)",
|
|
155
|
+
digestScopeMessage: "【消息差评】",
|
|
156
|
+
digestScopeSession: "【会话备注】",
|
|
157
|
+
digestCategorySuffix: "(分类 {category})",
|
|
158
|
+
lessonsNoHistory: "(本项目暂无历史教训记录)",
|
|
159
|
+
digestCommandDescription: "把本会话人工差评与近期教训蒸馏为候选规则(lesson-loop,人工触发)",
|
|
160
|
+
digestInputHint: "[附加说明]",
|
|
161
|
+
digestRejectedDisabled: "lesson-loop 的总开关或上报开关处于关闭状态,未执行蒸馏。",
|
|
162
|
+
digestRejectedNoSession: "/lessons-digest 需要在会话内执行(找不到当前会话)。",
|
|
163
|
+
digestRejectedNoLlm: "llm 服务不可用,无法蒸馏。",
|
|
164
|
+
digestNoNewRules: "差评 {count} 条已分析,未归纳出新规则(可能已存在或信号不足)。",
|
|
165
|
+
digestCreated: "已蒸馏 {count} 条候选规则(待你在设置卡确认后生效):\n{lines}",
|
|
166
|
+
digestCreatedLine: "{index}. [{category}] {statement}",
|
|
167
|
+
digestFailed: "蒸馏失败:{reason}",
|
|
168
|
+
errNoModelSelection: "当前无可用模型选择(agentDefaultModel)",
|
|
169
|
+
errMissingFinishChunk: "模型返回流缺少 finish 块",
|
|
170
|
+
errIncompleteFinish: "模型整理未正常完成({kind}{failure})",
|
|
171
|
+
errEmptyDigest: "模型整理结果为空",
|
|
172
|
+
errInvalidJsonBody: "请求体不是合法 JSON",
|
|
173
|
+
errIdAndActionRequired: "缺少 id 或 action",
|
|
174
|
+
errUnknownAction: "未知操作:{action}",
|
|
175
|
+
errRuleNotFound: "找不到该规则",
|
|
176
|
+
errRuleActionFailed: "规则操作失败"
|
|
177
|
+
},
|
|
178
|
+
en: {
|
|
179
|
+
sectionText: [
|
|
180
|
+
"## lesson-loop self-evolution loop",
|
|
181
|
+
"This environment runs a lesson loop: guard denials, gate failures, abnormal resumptions and human negative feedback are recorded as project lessons, lessons reaching the threshold are distilled into candidate rules, and a rule becomes an armed rule only after the user confirms it in the settings card — armed rules are injected at the start of each session.",
|
|
182
|
+
"- When a guard denies you: supply the missing evidence as the denial says, then retry. Do not rephrase your way around it; the denial itself is a learnable signal.",
|
|
183
|
+
"- If a session starts with injected experience rules, treat them as high-priority constraints and follow them while working.",
|
|
184
|
+
"- The user can run /lessons-digest to distill this session's negative feedback into lessons."
|
|
185
|
+
].join("\n"),
|
|
186
|
+
digestHeading: "[lesson-loop] Experience rules for this project (human-confirmed; follow them while working — violations are measured):",
|
|
187
|
+
digestRuleLine: "{index}. {statement} (armed on {day}{metrics})",
|
|
188
|
+
armedMetrics: ", {violation} violations / {suppressed} followed / {samples} exposed since arming",
|
|
189
|
+
armedNoTrigger: ", present in {samples} sessions since arming but never triggered (no evidence to judge yet)",
|
|
190
|
+
statusCandidate: "pending review",
|
|
191
|
+
statusArmed: "armed",
|
|
192
|
+
statusDemoted: "demoted, needs review",
|
|
193
|
+
statusRejected: "rejected",
|
|
194
|
+
statusArchived: "archived",
|
|
195
|
+
ruleSummary: "[{label}]{statement} ({category} · {signature} · {occurrences} observations{metrics})",
|
|
196
|
+
ruleSummaryArmed: " · {violation} violations / {suppressed} followed / {samples} exposed",
|
|
197
|
+
statementFactgateStable: "Before editing a file, read it successfully first and run a project-wide reference search (grep/glob/zg_search over the referencing surface) to confirm the blast radius — call sites, dependents, tests — before touching anything.",
|
|
198
|
+
statementFactgateRejected: "Archived summary of the rejected historical path fragments ({signature}): the requirement to gather evidence before editing is carried by the armed rule above; this entry only keeps the rejection record and the evidence — it is never injected and never measured.",
|
|
199
|
+
statementFactgatePath: "Before editing {signature}, read that file successfully first and run a project-wide reference search (grep/glob/zg_search over the referencing surface); close the test/e2e/docs evidence tiers before making the change.",
|
|
200
|
+
statementDangerousBash: "Avoid dangerous shell shapes ({signature}). When the same operation is really needed, explain the risk to the user first or switch to a safe alternative (drop --no-verify, confirm before deleting, use a package manager instead of curl|sh).",
|
|
201
|
+
statementSecretPathStable: "Do not edit secret/credential files (.env, *.key, token configs and the like); when a change is genuinely required, get the user's explicit consent first and keep the touched surface minimal.",
|
|
202
|
+
statementSecretPathTarget: "Do not edit secret/credential files ({signature}); when a change is genuinely required, get the user's explicit consent first and keep the touched surface minimal.",
|
|
203
|
+
statementGateFailure: "Before ending the turn, run {signature} locally and fix everything it reports; this gate has failed repeatedly in this project, so never assume \"it should pass\".",
|
|
204
|
+
statementTransientFailure: "{signature} has failed transiently before (rate limit / server side / timeout). When the same kind of failure shows up, stay calm and wait for the automatic resumption — do not discard the progress you already have.",
|
|
205
|
+
statementMaxTokens: "Deliver long output tasks in segments on purpose, so a single turn never hits the max-tokens ceiling and gets truncated.",
|
|
206
|
+
statementUnfinishedTurn: "Close out the task list before ending the turn: tick an item as soon as it is done, and never end a turn with unresolved to-dos.",
|
|
207
|
+
statementGeneric: "Do not repeat the following problem: {signature} (see the evidence).",
|
|
208
|
+
digestSystemRole: "You are an engineering-quality coach. The input is the human negative feedback from one session plus this project's historical lessons; distil the feedback into actionable candidate rules.",
|
|
209
|
+
digestSystemShape: "Output strictly a JSON array and nothing else. Each item has this shape:",
|
|
210
|
+
digestSystemShapeSignature: "a stable short key for the same kind of problem, e.g. command/path/scenario name",
|
|
211
|
+
digestSystemShapeStatement: "the rule statement: concrete, actionable, decidable — say what to do, not merely what not to do",
|
|
212
|
+
digestSystemRules: "Requirements: every rule must be an actionable instruction on how to avoid the problem next time; merge feedback sharing one cause; output [] when there is no negative-feedback signal; never invent facts absent from the feedback.",
|
|
213
|
+
digestSectionFeedback: "## Human negative feedback in this session",
|
|
214
|
+
digestSectionBackground: "## Historical lessons of this project (background, do not re-propose existing rules)",
|
|
215
|
+
digestSectionExtra: "## User's additional note",
|
|
216
|
+
digestNoFeedback: "(none)",
|
|
217
|
+
digestScopeMessage: "[message feedback]",
|
|
218
|
+
digestScopeSession: "[session note]",
|
|
219
|
+
digestCategorySuffix: " (category {category})",
|
|
220
|
+
lessonsNoHistory: "(no lesson records for this project yet)",
|
|
221
|
+
digestCommandDescription: "Distil this session's human negative feedback and recent lessons into candidate rules (lesson-loop, manually triggered)",
|
|
222
|
+
digestInputHint: "[additional note]",
|
|
223
|
+
digestRejectedDisabled: "lesson-loop's master switch or report switch is off — distillation was not run.",
|
|
224
|
+
digestRejectedNoSession: "/lessons-digest must run inside a session (no current session found).",
|
|
225
|
+
digestRejectedNoLlm: "The llm service is unavailable — distillation is impossible.",
|
|
226
|
+
digestNoNewRules: "{count} negative feedback item(s) analysed; no new rule was distilled (it may already exist or the signal is too weak).",
|
|
227
|
+
digestCreated: "{count} candidate rule(s) distilled (they take effect once you confirm them in the settings card):\n{lines}",
|
|
228
|
+
digestCreatedLine: "{index}. [{category}] {statement}",
|
|
229
|
+
digestFailed: "Distillation failed: {reason}",
|
|
230
|
+
errNoModelSelection: "No model selection is available (agentDefaultModel)",
|
|
231
|
+
errMissingFinishChunk: "The model stream is missing its finish chunk",
|
|
232
|
+
errIncompleteFinish: "The model digest did not finish normally ({kind}{failure})",
|
|
233
|
+
errEmptyDigest: "The model digest result is empty",
|
|
234
|
+
errInvalidJsonBody: "invalid json body",
|
|
235
|
+
errIdAndActionRequired: "id and action required",
|
|
236
|
+
errUnknownAction: "unknown action: {action}",
|
|
237
|
+
errRuleNotFound: "rule not found",
|
|
238
|
+
errRuleActionFailed: "rule action failed"
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region lib/statement-draft.ts
|
|
243
|
+
/**
|
|
244
|
+
* 候选规则 statement 模板:按分类起草,signature 原文嵌入(可读性优先)。
|
|
245
|
+
* @param category - 教训分类(决定模板选型)。
|
|
246
|
+
* @param signature - 归一后的签名(嵌进模板,或作为 feedback-digest 的正文)。
|
|
247
|
+
* @param messages - 当前语言的消息表。
|
|
248
|
+
* @returns 起草出的规则正文。
|
|
249
|
+
*/
|
|
250
|
+
function draftStatement(category, signature, messages) {
|
|
251
|
+
switch (category) {
|
|
252
|
+
case "factgate-deny":
|
|
253
|
+
if (signature === CATEGORY_SIGNATURES["factgate-deny"]) return messages.statementFactgateStable;
|
|
254
|
+
if (signature.endsWith(":rejected")) return fill(messages.statementFactgateRejected, { signature });
|
|
255
|
+
return fill(messages.statementFactgatePath, { signature });
|
|
256
|
+
case "dangerous-bash": return fill(messages.statementDangerousBash, { signature });
|
|
257
|
+
case "secret-path":
|
|
258
|
+
if (signature === CATEGORY_SIGNATURES["secret-path"]) return messages.statementSecretPathStable;
|
|
259
|
+
return fill(messages.statementSecretPathTarget, { signature });
|
|
260
|
+
case "gate-failure": return fill(messages.statementGateFailure, { signature });
|
|
261
|
+
case "transient-failure": return fill(messages.statementTransientFailure, { signature });
|
|
262
|
+
case "max-tokens": return messages.statementMaxTokens;
|
|
263
|
+
case "unfinished-turn": return messages.statementUnfinishedTurn;
|
|
264
|
+
case "feedback-digest": return signature;
|
|
265
|
+
default: return fill(messages.statementGeneric, { signature });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region lib/decay-policy.ts
|
|
270
|
+
const DEFAULT_DECAY = {
|
|
271
|
+
demoteThreshold: 3,
|
|
272
|
+
demoteMinSamples: 5,
|
|
273
|
+
demoteRatio: .5,
|
|
274
|
+
decayDays: 30
|
|
275
|
+
};
|
|
276
|
+
/** 单条 armed 规则的衰减裁决:'demote'(有真实证据的高复发率)| 'undeterminable'
|
|
277
|
+
* (armed 够久却既无复发也无一次干净命中,机器无从裁定)| 'keep'。 */
|
|
278
|
+
function decayVerdict(rule, policy, now) {
|
|
279
|
+
if (rule.status !== "armed") return "keep";
|
|
280
|
+
const evidence = rule.violation + rule.suppressed;
|
|
281
|
+
const matured = now - (rule.lastViolationAt ?? rule.lastSuppressedAt ?? rule.armedAt ?? rule.createdAt) > policy.decayDays * 864e5;
|
|
282
|
+
if (rule.suppressed === 0 && (matured || rule.violation >= policy.demoteThreshold)) return "undeterminable";
|
|
283
|
+
if (rule.suppressed > 0 && rule.violation >= policy.demoteThreshold && evidence >= policy.demoteMinSamples && rule.violation / evidence >= policy.demoteRatio) return "demote";
|
|
284
|
+
return "keep";
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region lib/lesson-store.ts
|
|
288
|
+
const RULE_STATUSES = /* @__PURE__ */ new Set([
|
|
289
|
+
"candidate",
|
|
290
|
+
"armed",
|
|
291
|
+
"demoted",
|
|
292
|
+
"rejected",
|
|
293
|
+
"archived"
|
|
294
|
+
]);
|
|
295
|
+
function isRuleStatus(value) {
|
|
296
|
+
return typeof value === "string" && RULE_STATUSES.has(value);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* `/lessons-digest` 蒸馏生产者的来源名——**同一个串在两处枚举面出现**:既是内置来源
|
|
300
|
+
* `KnownLessonSource` 的一员(`digest.ts` 上报时带它),也是蒸馏建卡的 `RuleOrigin`
|
|
301
|
+
* (`addCandidate` 的默认 source 与 origin)。字面量写四遍就改四处,故收成这一枚。
|
|
302
|
+
*/
|
|
303
|
+
const LESSONS_DIGEST_SOURCE = "lessons-digest";
|
|
304
|
+
/** 内置五个之外的兜底来源名。 */
|
|
305
|
+
const FALLBACK_LESSON_SOURCE = "manual";
|
|
306
|
+
/** 来源名长度上限:防把整段错误文本塞进来源字段(枚举面失控、卡片无法归因)。 */
|
|
307
|
+
const MAX_LESSON_SOURCE_LENGTH = 64;
|
|
308
|
+
/**
|
|
309
|
+
* 来源归一(开放集):trim 后非空且不过长 → 原样保留(第三方插件得以自报家门,
|
|
310
|
+
* 其教训与内置来源一样可入库、可统计);空/非字符串/超限 → manual。
|
|
311
|
+
*
|
|
312
|
+
* 为什么退 manual 而不是拒收:manual 是"有人记了这条、但说不出是谁"的最保守归属,
|
|
313
|
+
* 不因此把它当成某个内置生产者的机器上报(内置来源的归因语义不许被稀释)。
|
|
314
|
+
*/
|
|
315
|
+
function normalizeLessonSource(value) {
|
|
316
|
+
if (typeof value !== "string") return FALLBACK_LESSON_SOURCE;
|
|
317
|
+
const trimmed = value.trim();
|
|
318
|
+
if (trimmed.length === 0 || trimmed.length > MAX_LESSON_SOURCE_LENGTH) return FALLBACK_LESSON_SOURCE;
|
|
319
|
+
return trimmed;
|
|
320
|
+
}
|
|
321
|
+
/** 归一后落回 manual 的"本来说了别的"情形:必须留日志,否则第三方接入只表现为静默降级。 */
|
|
322
|
+
function noteSourceFallback(raw, source) {
|
|
323
|
+
if (source === FALLBACK_LESSON_SOURCE && typeof raw === "string" && raw.trim().length > 0) console.warn(`[lesson-loop] lesson source "${raw.trim().slice(0, 72)}" 不可用(非空但超 ${String(MAX_LESSON_SOURCE_LENGTH)} 字符),按 manual 记账`);
|
|
324
|
+
return source;
|
|
325
|
+
}
|
|
326
|
+
const RULE_ORIGINS = /* @__PURE__ */ new Set([
|
|
327
|
+
"threshold",
|
|
328
|
+
LESSONS_DIGEST_SOURCE,
|
|
329
|
+
"manual"
|
|
330
|
+
]);
|
|
331
|
+
function isRuleOrigin(value) {
|
|
332
|
+
return typeof value === "string" && RULE_ORIGINS.has(value);
|
|
333
|
+
}
|
|
334
|
+
/** origin 归一:字段缺失的存量卡都出自阈值归并(digest 建卡更晚),故退 threshold。 */
|
|
335
|
+
function originOf(value) {
|
|
336
|
+
return isRuleOrigin(value) ? value : "threshold";
|
|
337
|
+
}
|
|
338
|
+
/** status 归一:漂移状态退 archived(不注入、不度量的最保守态),人工可 revive。 */
|
|
339
|
+
function statusOf(value) {
|
|
340
|
+
return isRuleStatus(value) ? value : "archived";
|
|
341
|
+
}
|
|
342
|
+
/** 字符串字段归一:非字符串(缺字段/类型漂移)退兜底,绝不整条丢弃。 */
|
|
343
|
+
function strField(value, fallback) {
|
|
344
|
+
return typeof value === "string" ? value : fallback;
|
|
345
|
+
}
|
|
346
|
+
/** 数值字段读取:只认 number 与数字字符串两种存量形态;NaN/null/布尔/对象一律视为漂移。 */
|
|
347
|
+
function numericOf(value) {
|
|
348
|
+
let result;
|
|
349
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
350
|
+
const parsed = Number(value);
|
|
351
|
+
if (Number.isFinite(parsed)) result = parsed;
|
|
352
|
+
}
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
/** 必填数值字段归一:漂移退兜底(计数与时间戳缺了就没法渲染、排序、衰减)。 */
|
|
356
|
+
function numField(value, fallback) {
|
|
357
|
+
return numericOf(value) ?? fallback;
|
|
358
|
+
}
|
|
359
|
+
/** 未知数组:非数组退空表(不经 any 扩散,逐元素仍是 unknown)。 */
|
|
360
|
+
function unknownArray$1(value) {
|
|
361
|
+
return Array.isArray(value) ? value : [];
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 落盘失败回执的 reason(与 not-found 区分:前者是"没存住、可重试",后者是 id 错了)。
|
|
365
|
+
*
|
|
366
|
+
* 一次写不出两类失败,全部收成这一个值,因为它对调用方只有一层含义:**这一次改动没
|
|
367
|
+
* 有进到规则库**。为什么不再是旧契约那句"内存已改、只是没落盘"——本文件已没有跨调用
|
|
368
|
+
* 存活的内存数组(每笔改动都从端口重读、在"这一刻"的数组上重放,见 LessonStore.commit),
|
|
369
|
+
* "没落盘"因此就等于"没生效":
|
|
370
|
+
* (a) 端口明确拒绝(rejected,含 usable:false 的不可写面):连别人的最新状态都没读到
|
|
371
|
+
* 可信值,这一次改动既不许写出去、也不留在任何地方——留着就是替坏数据背书。
|
|
372
|
+
* (b) CAS 冲突耗尽(conflict 用满 RULES_CAS_RETRY_LIMIT):对方确实在推进 revision,
|
|
373
|
+
* 这一次改动同样不外推、不缓存,重试权交回拿着回执的调用方;下一笔仍旧先重读端口,
|
|
374
|
+
* 于是永远不可能拿陈旧内存覆掉别人写好的卡。
|
|
375
|
+
* 两类共用一个回执值:调用方的处置动作(提示重试)完全一致,差别只落在端口回执与
|
|
376
|
+
* commit 的重试行为上((a) 不重试、(b) 重读到上限)。
|
|
377
|
+
*/
|
|
378
|
+
const PERSIST_FAILED = "rules-not-persisted";
|
|
379
|
+
/**
|
|
380
|
+
* CAS 冲突后的"重读 + 重放"上限。
|
|
381
|
+
*
|
|
382
|
+
* 3 次足够:冲突只会来自"另一个进程/另一条写队列在我读之后推进了 revision",
|
|
383
|
+
* 每轮重试都会拿到含对方改动的新数组,重放的是本进程这一次改动。超过上限仍冲突
|
|
384
|
+
* 说明写队列被卡住:按 PERSIST_FAILED 注释里的 (b) 类回执,不外抛,也不把这一次改动
|
|
385
|
+
* 缓存下来等下一笔顺手写走。
|
|
386
|
+
* ⚠ 上限值只在 commit() 用;测试侧要钉的是"读 N 次、写 N 次"这个数本身,故那边写死字面量
|
|
387
|
+
* (引常量就成了自证),这里不再开 export 面。
|
|
388
|
+
*/
|
|
389
|
+
const RULES_CAS_RETRY_LIMIT = 3;
|
|
390
|
+
/** cwd → 项目桶。派生本体在 shared/lib/project-key.ts(与 quality-gate 的
|
|
391
|
+
* deriveAgentId 同源);此处只留本包的既有导出名,别再长第二份实现。 */
|
|
392
|
+
function deriveProject(cwd) {
|
|
393
|
+
return deriveProjectKey(cwd);
|
|
394
|
+
}
|
|
395
|
+
/** 可选字符串字段:非空字符串才认(缺失/空串/类型漂移一律视为"没有这个字段",
|
|
396
|
+
* 读写两侧都不写 undefined 键——exactOptionalPropertyTypes 下键存在与否有语义)。 */
|
|
397
|
+
function nonEmptyString(value) {
|
|
398
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
399
|
+
}
|
|
400
|
+
function observeLesson(rec, now) {
|
|
401
|
+
const rawSignature = normalizeSignature(rec.signature);
|
|
402
|
+
const cwd = nonEmptyString(rec.cwd);
|
|
403
|
+
return {
|
|
404
|
+
rec,
|
|
405
|
+
now,
|
|
406
|
+
rawSignature,
|
|
407
|
+
signature: stableSignatureFor(rec.category, rawSignature),
|
|
408
|
+
...cwd === void 0 ? {} : { cwd }
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** 证据行组装:发生折叠时把原始签名(路径/命令行)随证据落下,键只管汇聚。 */
|
|
412
|
+
function evidenceRow(ts, source, detail, raw, folded) {
|
|
413
|
+
return {
|
|
414
|
+
ts,
|
|
415
|
+
source,
|
|
416
|
+
detail,
|
|
417
|
+
...raw === folded ? {} : { signature: raw }
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** 状态权威序(同键撞卡时谁当主卡):人工决定 > 机器状态,与 migrateFragmentRules
|
|
421
|
+
* "人工升格/拒绝不被机器改写"的同一条红线;同级再看观察次数。 */
|
|
422
|
+
const STATUS_AUTHORITY = {
|
|
423
|
+
candidate: 0,
|
|
424
|
+
demoted: 0,
|
|
425
|
+
archived: 1,
|
|
426
|
+
rejected: 2,
|
|
427
|
+
armed: 3
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* 挑主卡:返回 [主卡, 被并卡]。人工状态优先(armed > rejected > archived > 候选/
|
|
431
|
+
* 降级),同级取 occurrences 多者(保持入参顺序优先,结果与卡片在列表里的位置无关)。
|
|
432
|
+
* 因为 armed 一定胜出,被丢掉的那张不可能带着未迁移的 violation/suppressed ——
|
|
433
|
+
* 不需要 migrateProjectFragments 里那步 inheritArmed。
|
|
434
|
+
*/
|
|
435
|
+
function pickMergePair(left, right) {
|
|
436
|
+
const leftAuthority = STATUS_AUTHORITY[left.status];
|
|
437
|
+
const rightAuthority = STATUS_AUTHORITY[right.status];
|
|
438
|
+
if (leftAuthority > rightAuthority) return [left, right];
|
|
439
|
+
if (rightAuthority > leftAuthority) return [right, left];
|
|
440
|
+
return left.occurrences >= right.occurrences ? [left, right] : [right, left];
|
|
441
|
+
}
|
|
442
|
+
/** 证据行归一:逐字段补默认(非对象的证据元素在 unknownArray 之后仍可能为 null)。 */
|
|
443
|
+
function normalizeEvidenceRow(value) {
|
|
444
|
+
const row = {
|
|
445
|
+
ts: numField(fieldOf(value, "ts"), 0),
|
|
446
|
+
source: normalizeLessonSource(fieldOf(value, "source")),
|
|
447
|
+
detail: strField(fieldOf(value, "detail"), "")
|
|
448
|
+
};
|
|
449
|
+
const original = nonEmptyString(fieldOf(value, "signature"));
|
|
450
|
+
if (original !== void 0) row.signature = original;
|
|
451
|
+
return row;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* 规则卡逐字段归一(存量文件漂移容错)。
|
|
455
|
+
*
|
|
456
|
+
* 旧实现只查 id/category/status 就断言整张 RuleCard,缺 evidence/sources 的存量卡
|
|
457
|
+
* 会在 report() 的 rule.evidence.push 处 TypeError——一条坏记录让总线**永久**抛错,
|
|
458
|
+
* 而总线在守卫热路径上(danger-guard 每次拒绝都调它),等于整个自进化环瘫掉。
|
|
459
|
+
* 现在只有 id 是硬要求(人工动作与度量都按 id 定位,缺 id 的记录无处安放),
|
|
460
|
+
* 其余字段一律补默认值;status 漂移退 archived(不注入、不度量),见 statusOf。
|
|
461
|
+
*/
|
|
462
|
+
function normalizeRuleCardRow(value) {
|
|
463
|
+
const id = fieldOf(value, "id");
|
|
464
|
+
if (typeof id !== "string" || id === "") return null;
|
|
465
|
+
const armedAt = numericOf(fieldOf(value, "armedAt"));
|
|
466
|
+
const lastSeenAt = numericOf(fieldOf(value, "lastSeenAt"));
|
|
467
|
+
const lastViolationAt = numericOf(fieldOf(value, "lastViolationAt"));
|
|
468
|
+
const lastSuppressedAt = numericOf(fieldOf(value, "lastSuppressedAt"));
|
|
469
|
+
const cardCwd = nonEmptyString(fieldOf(value, "cwd"));
|
|
470
|
+
const row = {
|
|
471
|
+
id,
|
|
472
|
+
project: strField(fieldOf(value, "project"), "default"),
|
|
473
|
+
category: strField(fieldOf(value, "category"), "unknown"),
|
|
474
|
+
signature: normalizeSignature(fieldOf(value, "signature")),
|
|
475
|
+
statement: strField(fieldOf(value, "statement"), ""),
|
|
476
|
+
status: statusOf(fieldOf(value, "status")),
|
|
477
|
+
createdAt: numField(fieldOf(value, "createdAt"), 0),
|
|
478
|
+
updatedAt: numField(fieldOf(value, "updatedAt"), 0),
|
|
479
|
+
occurrences: numField(fieldOf(value, "occurrences"), 0),
|
|
480
|
+
sources: unknownArray$1(fieldOf(value, "sources")).map((entry) => normalizeLessonSource(entry)),
|
|
481
|
+
violation: numField(fieldOf(value, "violation"), 0),
|
|
482
|
+
suppressed: numField(fieldOf(value, "suppressed"), 0),
|
|
483
|
+
samples: numField(fieldOf(value, "samples"), 0),
|
|
484
|
+
recurrences: numField(fieldOf(value, "recurrences"), 0),
|
|
485
|
+
evidence: unknownArray$1(fieldOf(value, "evidence")).filter((entry) => isRecord(entry)).map((entry) => normalizeEvidenceRow(entry)),
|
|
486
|
+
origin: originOf(fieldOf(value, "origin"))
|
|
487
|
+
};
|
|
488
|
+
if (armedAt !== void 0) row.armedAt = armedAt;
|
|
489
|
+
if (cardCwd !== void 0) row.cwd = cardCwd;
|
|
490
|
+
if (lastSeenAt !== void 0) row.lastSeenAt = lastSeenAt;
|
|
491
|
+
if (lastViolationAt !== void 0) row.lastViolationAt = lastViolationAt;
|
|
492
|
+
if (lastSuppressedAt !== void 0) row.lastSuppressedAt = lastSuppressedAt;
|
|
493
|
+
return row;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* 教训行逐字段归一(JSONL 存量漂移容错)。与规则卡同一取向:category/signature
|
|
497
|
+
* 之外的字段漂移不再整行丢弃,否则一次版本升级就把历史教训从管理面与蒸馏背景
|
|
498
|
+
* 里抹掉(丢掉的正是要拿去归纳规则的材料)。
|
|
499
|
+
*/
|
|
500
|
+
function normalizeLessonRow(value) {
|
|
501
|
+
const category = fieldOf(value, "category");
|
|
502
|
+
const signature = fieldOf(value, "signature");
|
|
503
|
+
if (typeof category !== "string" || typeof signature !== "string") return null;
|
|
504
|
+
const cwd = fieldOf(value, "cwd");
|
|
505
|
+
const sessionId = fieldOf(value, "sessionId");
|
|
506
|
+
const turn = numericOf(fieldOf(value, "turn"));
|
|
507
|
+
const evidence = fieldOf(value, "evidence");
|
|
508
|
+
const row = {
|
|
509
|
+
ts: numField(fieldOf(value, "ts"), 0),
|
|
510
|
+
source: normalizeLessonSource(fieldOf(value, "source")),
|
|
511
|
+
category,
|
|
512
|
+
project: strField(fieldOf(value, "project"), "default"),
|
|
513
|
+
signature,
|
|
514
|
+
detail: strField(fieldOf(value, "detail"), "")
|
|
515
|
+
};
|
|
516
|
+
if (typeof cwd === "string") row.cwd = cwd;
|
|
517
|
+
if (typeof sessionId === "string") row.sessionId = sessionId;
|
|
518
|
+
if (turn !== void 0) row.turn = turn;
|
|
519
|
+
if (isRecord(evidence)) row.evidence = evidence;
|
|
520
|
+
return row;
|
|
521
|
+
}
|
|
522
|
+
/** 数字阈值归一:非法/越界用默认值(供 policy() 共用,无父级捕获)。 */
|
|
523
|
+
function num(value, fallback, min = 0) {
|
|
524
|
+
return Number.isFinite(Number(value)) && Number(value) >= min ? Number(value) : fallback;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* 规则卡骨架:两条建卡路径必须长出同一形状的卡(状态一律 candidate、计数一律从 1/0
|
|
528
|
+
* 起步),否则其中一条路会造出"缺字段靠读取侧补默认"的卡——度量语义就漂了。
|
|
529
|
+
*/
|
|
530
|
+
function newRuleCard(args) {
|
|
531
|
+
const { project, category, signature, statement, origin, source, evidence, lastSeenAt, now } = args;
|
|
532
|
+
return {
|
|
533
|
+
id: `rule-${randomUUID()}`,
|
|
534
|
+
project,
|
|
535
|
+
category,
|
|
536
|
+
signature,
|
|
537
|
+
statement,
|
|
538
|
+
status: "candidate",
|
|
539
|
+
createdAt: now,
|
|
540
|
+
updatedAt: now,
|
|
541
|
+
occurrences: 1,
|
|
542
|
+
sources: [source],
|
|
543
|
+
violation: 0,
|
|
544
|
+
suppressed: 0,
|
|
545
|
+
samples: 0,
|
|
546
|
+
recurrences: 0,
|
|
547
|
+
lastSeenAt,
|
|
548
|
+
evidence: [evidence],
|
|
549
|
+
origin,
|
|
550
|
+
...args.cwd === void 0 ? {} : { cwd: args.cwd }
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** 人工改写的正文:非空 trim 才认(空/缺省 = 保留卡片原文)。 */
|
|
554
|
+
function trimmedStatement(statement) {
|
|
555
|
+
const trimmed = typeof statement === "string" ? statement.trim() : "";
|
|
556
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* 人工动作落地。arm 会清零历史度量:复发/干净/暴露三项计数属于**那一次升格**的
|
|
560
|
+
* 生命周期,带着旧值进新一轮会让降级裁决读到混合证据。
|
|
561
|
+
*/
|
|
562
|
+
function applyRuleAction$1(rule, action, statement, now) {
|
|
563
|
+
const nextStatement = trimmedStatement(statement);
|
|
564
|
+
switch (action) {
|
|
565
|
+
case "arm":
|
|
566
|
+
if (nextStatement !== void 0) rule.statement = nextStatement;
|
|
567
|
+
rule.status = "armed";
|
|
568
|
+
rule.armedAt = now;
|
|
569
|
+
rule.violation = 0;
|
|
570
|
+
rule.suppressed = 0;
|
|
571
|
+
rule.samples = 0;
|
|
572
|
+
rule.recurrences = 0;
|
|
573
|
+
delete rule.lastViolationAt;
|
|
574
|
+
delete rule.lastSuppressedAt;
|
|
575
|
+
break;
|
|
576
|
+
case "reject":
|
|
577
|
+
rule.status = "rejected";
|
|
578
|
+
rule.recurrences = 0;
|
|
579
|
+
break;
|
|
580
|
+
case "demote":
|
|
581
|
+
rule.status = "demoted";
|
|
582
|
+
break;
|
|
583
|
+
case "archive":
|
|
584
|
+
rule.status = "archived";
|
|
585
|
+
break;
|
|
586
|
+
case "revive":
|
|
587
|
+
rule.status = "candidate";
|
|
588
|
+
rule.recurrences = 0;
|
|
589
|
+
break;
|
|
590
|
+
case "edit": if (nextStatement !== void 0) rule.statement = nextStatement;
|
|
591
|
+
}
|
|
592
|
+
rule.updatedAt = now;
|
|
593
|
+
}
|
|
594
|
+
/** 就地剔出一批卡(commit 的工作数组是本次读取的副本,剔除只影响这份待写内容)。 */
|
|
595
|
+
function dropCards(cards, doomed) {
|
|
596
|
+
for (let cursor = cards.length - 1; cursor >= 0; cursor -= 1) {
|
|
597
|
+
const row = cards[cursor];
|
|
598
|
+
if (row !== void 0 && doomed.has(row.id)) cards.splice(cursor, 1);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** 同 (project, category, signature) 键命中。 */
|
|
602
|
+
function findByKey(cards, key) {
|
|
603
|
+
return cards.find((row) => ruleKey(row.project, row.category, row.signature) === key);
|
|
604
|
+
}
|
|
605
|
+
/** 归并目标卡挑选用的排序:观察次数多者在前(toSorted 不改入参,工作数组要保序)。 */
|
|
606
|
+
function byOccurrences(left, right) {
|
|
607
|
+
return right.occurrences - left.occurrences;
|
|
608
|
+
}
|
|
609
|
+
/** 零归并(该 project 这一组无卡可并)。 */
|
|
610
|
+
const CLEAN_PASS = {
|
|
611
|
+
merged: 0,
|
|
612
|
+
dirty: false
|
|
613
|
+
};
|
|
614
|
+
var LessonStore = class LessonStore {
|
|
615
|
+
opts;
|
|
616
|
+
repo;
|
|
617
|
+
now;
|
|
618
|
+
constructor(opts) {
|
|
619
|
+
this.opts = opts;
|
|
620
|
+
this.repo = opts.rules;
|
|
621
|
+
this.now = opts.now ?? Date.now;
|
|
622
|
+
}
|
|
623
|
+
/** 磁盘保险丝(settings 运行时可调;0 = 不设上限)。**这个字段唯一的写入归一口**:
|
|
624
|
+
* 非有限值与负数一律落 0——appendJsonl 判的是 `maxBytes > 0`,退 0 与留着负数是同
|
|
625
|
+
* 一件事,但只有归一过一次,读侧才不必再猜"库里那个数是不是半截的"。 */
|
|
626
|
+
setMaxLessonsBytes(bytes) {
|
|
627
|
+
this.opts.maxLessonsBytes = Number.isFinite(bytes) && bytes >= 0 ? bytes : 0;
|
|
628
|
+
}
|
|
629
|
+
/** settings 拉模型:宿主每次 report/decay 前把运行时阈值同步进来(pull,无需订阅)。 */
|
|
630
|
+
configure(partial) {
|
|
631
|
+
for (const [key, val] of Object.entries(partial)) if (typeof val === "number" && Number.isFinite(val)) switch (key) {
|
|
632
|
+
case "promoteThreshold":
|
|
633
|
+
this.opts.promoteThreshold = val;
|
|
634
|
+
break;
|
|
635
|
+
case "demoteThreshold":
|
|
636
|
+
this.opts.demoteThreshold = val;
|
|
637
|
+
break;
|
|
638
|
+
case "demoteMinSamples":
|
|
639
|
+
this.opts.demoteMinSamples = val;
|
|
640
|
+
break;
|
|
641
|
+
case "demoteRatio":
|
|
642
|
+
this.opts.demoteRatio = val;
|
|
643
|
+
break;
|
|
644
|
+
case "decayDays":
|
|
645
|
+
this.opts.decayDays = val;
|
|
646
|
+
break;
|
|
647
|
+
case "maxLessonsBytes":
|
|
648
|
+
this.setMaxLessonsBytes(val);
|
|
649
|
+
break;
|
|
650
|
+
case "reviveThreshold": this.opts.reviveThreshold = val;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* 一次规则库改动:读 → 在**这一刻读到的数组**上重放改动 → 带 revision 的 CAS 写。
|
|
655
|
+
*
|
|
656
|
+
* 为什么必须"写前重读 + 带 revision":本进程不是规则库的唯一作者——常驻 web 实例
|
|
657
|
+
* 与一次性 headless 进程交替写同一段设置,而 provider 对 `rules` 这个数组键是
|
|
658
|
+
* **整片覆盖**(mergeLayers 不合并数组)。写回的数组若来自陈旧读,就会把别的进程
|
|
659
|
+
* 刚写进来的卡整片抹掉(此前实测到的丢卡缺陷)。revision 被别处推进过时 provider
|
|
660
|
+
* 抛 SettingsConflictError:重读合并后的值、在新数组上重放本次改动再写,最多
|
|
661
|
+
* RULES_CAS_RETRY_LIMIT 次;仍失败按 persist-failed 语义回执,绝不外抛——总线挂在
|
|
662
|
+
* 守卫热路径上(danger-guard 每次拒绝都调它),抛穿等于整个自进化环瘫掉。
|
|
663
|
+
*
|
|
664
|
+
* 失败分两类收手(同一回执 PERSIST_FAILED,理由见 PERSIST_FAILED 的注释):
|
|
665
|
+
* (a) 不可信读 / 端口明确拒绝 → **一次都不写、也不重放**:usable:false 时连"别人
|
|
666
|
+
* 此刻有什么"都读不懂,写回去就是替坏数据背书;rejected 是端口自身写不进去,
|
|
667
|
+
* 重放只会重复撞同一堵墙。本文件不留任何跨调用存活的数组,所以"不落内存"这条
|
|
668
|
+
* 在结构上是自动成立的——计划改的是本次读取的副本,副本随本函数返回即丢弃。
|
|
669
|
+
* (b) CAS 冲突(对方在推进 revision)→ 值得重放:每轮都拿含对方改动的新数组,
|
|
670
|
+
* 重放的是本进程这一次改动;用满上限仍冲突说明写队列被卡住,就此收手回执
|
|
671
|
+
* 失败,改动留在调用方手里的回执对象上(不缓存、不回滚端口)。
|
|
672
|
+
* 两类的共同底线:任何一次写都只可能带上"重读后那一刻"的他人卡,绝不整体回滚成
|
|
673
|
+
* 陈旧内存覆掉别人写好的卡。
|
|
674
|
+
*/
|
|
675
|
+
async commit(plan) {
|
|
676
|
+
const attempt = async (retryLeft) => {
|
|
677
|
+
const current = this.repo.load();
|
|
678
|
+
const trustworthy = current.usable;
|
|
679
|
+
const cards = trustworthy ? [...current.cards] : [];
|
|
680
|
+
const planned = plan(cards);
|
|
681
|
+
if (!planned.write) return {
|
|
682
|
+
ok: true,
|
|
683
|
+
value: planned.value,
|
|
684
|
+
trustworthy
|
|
685
|
+
};
|
|
686
|
+
if (!trustworthy) return {
|
|
687
|
+
ok: false,
|
|
688
|
+
value: planned.value,
|
|
689
|
+
trustworthy
|
|
690
|
+
};
|
|
691
|
+
const outcome = await this.repo.save(cards, current.revision);
|
|
692
|
+
if (outcome === "persisted") return {
|
|
693
|
+
ok: true,
|
|
694
|
+
value: planned.value,
|
|
695
|
+
trustworthy
|
|
696
|
+
};
|
|
697
|
+
if (outcome === "rejected" || retryLeft <= 0) return {
|
|
698
|
+
ok: false,
|
|
699
|
+
value: planned.value,
|
|
700
|
+
trustworthy
|
|
701
|
+
};
|
|
702
|
+
return attempt(retryLeft - 1);
|
|
703
|
+
};
|
|
704
|
+
return await attempt(RULES_CAS_RETRY_LIMIT);
|
|
705
|
+
}
|
|
706
|
+
threshold() {
|
|
707
|
+
const value = Number(this.opts.promoteThreshold);
|
|
708
|
+
return Number.isFinite(value) && value >= 1 ? Math.floor(value) : 3;
|
|
709
|
+
}
|
|
710
|
+
/** 起草用的消息表:每次现取(见 StoreOptions.messages),本文件不读 settings。 */
|
|
711
|
+
messages() {
|
|
712
|
+
return this.opts.messages();
|
|
713
|
+
}
|
|
714
|
+
policy() {
|
|
715
|
+
const { opts } = this;
|
|
716
|
+
return {
|
|
717
|
+
demoteThreshold: num(opts.demoteThreshold, DEFAULT_DECAY.demoteThreshold, 1),
|
|
718
|
+
demoteMinSamples: num(opts.demoteMinSamples, DEFAULT_DECAY.demoteMinSamples, 1),
|
|
719
|
+
demoteRatio: num(opts.demoteRatio, DEFAULT_DECAY.demoteRatio, .01),
|
|
720
|
+
decayDays: num(opts.decayDays, DEFAULT_DECAY.decayDays, 1)
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* 记一条教训:全量落事件流水 → 归并规则卡(armed 命中计复发;候选累积)。
|
|
725
|
+
*
|
|
726
|
+
* 键里的签名走 stableSignatureFor():登记表内的类别一律汇聚到类别级稳定签名,
|
|
727
|
+
* 原始路径/命令行改落 evidence.signature。碎片因此**不再产生**(旧数据由
|
|
728
|
+
* migrateFragmentRules 一次性收拢)。
|
|
729
|
+
*/
|
|
730
|
+
async report(record) {
|
|
731
|
+
const now = this.now();
|
|
732
|
+
const source = noteSourceFallback(record.source, normalizeLessonSource(record.source));
|
|
733
|
+
const rec = {
|
|
734
|
+
...record,
|
|
735
|
+
source,
|
|
736
|
+
ts: Number.isFinite(record.ts) ? record.ts : now
|
|
737
|
+
};
|
|
738
|
+
appendJsonl(this.opts.lessonsFile, rec, this.opts.maxLessonsBytes ?? 0);
|
|
739
|
+
const obs = observeLesson(rec, now);
|
|
740
|
+
const key = ruleKey(rec.project, rec.category, obs.signature);
|
|
741
|
+
const outcome = await this.commit((cards) => this.planReport(cards, obs, key));
|
|
742
|
+
return this.receiptFor(outcome);
|
|
743
|
+
}
|
|
744
|
+
/** 未命中任何键 → 按类别级稳定签名开一张新候选卡;命中 → 补证据 + 按状态计度量。 */
|
|
745
|
+
planReport(cards, obs, key) {
|
|
746
|
+
const rule = findByKey(cards, key);
|
|
747
|
+
return rule === void 0 ? {
|
|
748
|
+
value: {
|
|
749
|
+
kind: "created",
|
|
750
|
+
card: LessonStore.openCandidate(cards, obs, this.messages())
|
|
751
|
+
},
|
|
752
|
+
write: true
|
|
753
|
+
} : this.accumulate(rule, obs);
|
|
754
|
+
}
|
|
755
|
+
/** 首报:开一张新候选卡并放进待写数组(静态:只吃 obs 与递来的消息表,不读实例状态)。 */
|
|
756
|
+
static openCandidate(cards, obs, messages) {
|
|
757
|
+
const { rec, now, rawSignature, signature } = obs;
|
|
758
|
+
const card = newRuleCard({
|
|
759
|
+
project: rec.project,
|
|
760
|
+
category: rec.category,
|
|
761
|
+
signature,
|
|
762
|
+
statement: draftStatement(rec.category, signature, messages),
|
|
763
|
+
origin: "threshold",
|
|
764
|
+
source: rec.source,
|
|
765
|
+
lastSeenAt: rec.ts,
|
|
766
|
+
evidence: evidenceRow(rec.ts, rec.source, rec.detail, rawSignature, signature),
|
|
767
|
+
now,
|
|
768
|
+
...obs.cwd === void 0 ? {} : { cwd: obs.cwd }
|
|
769
|
+
});
|
|
770
|
+
cards.push(card);
|
|
771
|
+
return card;
|
|
772
|
+
}
|
|
773
|
+
/** 命中已有卡:补证据 + 按状态计复发/累积/复活。 */
|
|
774
|
+
accumulate(rule, obs) {
|
|
775
|
+
const { rec, now, rawSignature, signature } = obs;
|
|
776
|
+
rule.lastSeenAt = rec.ts;
|
|
777
|
+
rule.updatedAt = now;
|
|
778
|
+
rule.evidence.push(evidenceRow(rec.ts, rec.source, rec.detail, rawSignature, signature));
|
|
779
|
+
if (!rule.sources.includes(rec.source)) rule.sources.push(rec.source);
|
|
780
|
+
if (rule.cwd === void 0 && obs.cwd !== void 0) rule.cwd = obs.cwd;
|
|
781
|
+
if (rule.status === "armed") {
|
|
782
|
+
rule.violation += 1;
|
|
783
|
+
rule.lastViolationAt = rec.ts;
|
|
784
|
+
if (decayVerdict(rule, this.policy(), now) === "demote") {
|
|
785
|
+
rule.status = "demoted";
|
|
786
|
+
rule.updatedAt = now;
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
value: {
|
|
790
|
+
kind: "violation",
|
|
791
|
+
card: rule
|
|
792
|
+
},
|
|
793
|
+
write: true
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
if (rule.status === "candidate") {
|
|
797
|
+
rule.occurrences += 1;
|
|
798
|
+
return {
|
|
799
|
+
value: {
|
|
800
|
+
kind: "candidate",
|
|
801
|
+
card: rule
|
|
802
|
+
},
|
|
803
|
+
write: true
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (rule.status === "rejected" && this.noteRecurrence(rule, now)) return {
|
|
807
|
+
value: {
|
|
808
|
+
kind: "candidate",
|
|
809
|
+
card: rule
|
|
810
|
+
},
|
|
811
|
+
write: true
|
|
812
|
+
};
|
|
813
|
+
return {
|
|
814
|
+
value: {
|
|
815
|
+
kind: "settled",
|
|
816
|
+
card: rule
|
|
817
|
+
},
|
|
818
|
+
write: true
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
/** rejected 卡的复发计数:达阈值则转回候选(返回是否转回)。
|
|
822
|
+
* 转回的落点是 candidate(待人重审),不是 armed,也不清证据——机器不替人升格。 */
|
|
823
|
+
noteRecurrence(rule, now) {
|
|
824
|
+
rule.recurrences += 1;
|
|
825
|
+
if (rule.recurrences < this.reviveThreshold()) return false;
|
|
826
|
+
rule.status = "candidate";
|
|
827
|
+
rule.occurrences = rule.recurrences;
|
|
828
|
+
rule.recurrences = 0;
|
|
829
|
+
rule.updatedAt = now;
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
/** 组装 report() 回执:ready 只在确实落盘后才报(磁盘没存住就催人工审 = 点空)。 */
|
|
833
|
+
receiptFor(outcome) {
|
|
834
|
+
const { ok, value } = outcome;
|
|
835
|
+
const { kind, card } = value;
|
|
836
|
+
const threshold = this.threshold();
|
|
837
|
+
const ready = ok && (kind === "created" ? threshold <= 1 : card.occurrences >= threshold);
|
|
838
|
+
return {
|
|
839
|
+
ok,
|
|
840
|
+
...ok ? {} : { reason: PERSIST_FAILED },
|
|
841
|
+
...kind === "violation" ? { violationOf: card } : {},
|
|
842
|
+
...kind === "created" || kind === "candidate" ? {
|
|
843
|
+
candidate: card,
|
|
844
|
+
ready
|
|
845
|
+
} : {}
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/** revival 阈值(StoreOptions.reviveThreshold,归一 1-20,默认 3)。 */
|
|
849
|
+
reviveThreshold() {
|
|
850
|
+
const value = Number(this.opts.reviveThreshold);
|
|
851
|
+
return Number.isFinite(value) && value >= 1 ? Math.min(20, Math.floor(value)) : 3;
|
|
852
|
+
}
|
|
853
|
+
/** pass 信号:规则场景被触发且被遵守。会话收尾清算时只有被
|
|
854
|
+
* pass 过的 armed 规则才计 suppressed——无关会话不再灌水。
|
|
855
|
+
* 键与 report() 同源(stableSignatureFor),调用方继续传自己那条细签名。 */
|
|
856
|
+
pass(project, category, signature) {
|
|
857
|
+
const key = ruleKey(project, category, stableSignatureFor(category, signature));
|
|
858
|
+
return this.workingCards().find((row) => ruleKey(row.project, row.category, row.signature) === key && row.status === "armed");
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* 会话收尾清算:对本项目内每条「armed 且本会话未违规」的规则记一次暴露 samples
|
|
862
|
+
* (测没测过这条规则的判据,与 pass 无关);其中被 pass 过的(规则场景触发且被
|
|
863
|
+
* 遵守)再计一次 suppressed=observed。被本会话违反的规则既不记 samples 也不记
|
|
864
|
+
* observed(violation 已由 report() 计)。samples 只增暴露度,绝不进复发率分母。
|
|
865
|
+
*/
|
|
866
|
+
async sessionEnded(project, violatedRuleIds, passedRuleIds, sessionId) {
|
|
867
|
+
const now = this.now();
|
|
868
|
+
if ((await this.commit((cards) => {
|
|
869
|
+
let dirty = false;
|
|
870
|
+
for (const rule of cards) if (rule.status === "armed" && rule.project === project && !violatedRuleIds.has(rule.id)) {
|
|
871
|
+
rule.samples += 1;
|
|
872
|
+
rule.updatedAt = now;
|
|
873
|
+
dirty = true;
|
|
874
|
+
if (passedRuleIds.has(rule.id)) {
|
|
875
|
+
rule.suppressed += 1;
|
|
876
|
+
rule.lastSuppressedAt = now;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
value: dirty,
|
|
881
|
+
write: dirty
|
|
882
|
+
};
|
|
883
|
+
})).value && sessionId !== void 0) console.info(`[lesson-loop] ${sessionId}: session settled for project ${project} (${String(passedRuleIds.size)} passed rule(s))`);
|
|
884
|
+
}
|
|
885
|
+
/** 读这一刻的规则数组(每次都是新对象:改动只作用于本次的待写副本)。
|
|
886
|
+
* usable:false 时一律读空——"读不懂"不许当成"真的没有",也不许当成"还有这些卡"
|
|
887
|
+
* 拿去注入/评审(那是把坏数据洗成界面事实)。 */
|
|
888
|
+
workingCards() {
|
|
889
|
+
const current = this.repo.load();
|
|
890
|
+
return current.usable ? [...current.cards] : [];
|
|
891
|
+
}
|
|
892
|
+
rules() {
|
|
893
|
+
return this.workingCards();
|
|
894
|
+
}
|
|
895
|
+
/** 人工动作:arm(可携改写后的 statement)/ reject / demote / archive / edit / revive。
|
|
896
|
+
* 返回 not-found = 可信读面上确实无此卡(id 错了,重试也没用);persist-failed = 这一次
|
|
897
|
+
* 没存住(含端口读不懂的那种),调用方提示重试,绝不当成"规则不存在"。 */
|
|
898
|
+
async ruleAction(id, action, statement) {
|
|
899
|
+
const outcome = await this.commit((cards) => {
|
|
900
|
+
const rule = cards.find((row) => row.id === id);
|
|
901
|
+
if (rule === void 0) return {
|
|
902
|
+
value: false,
|
|
903
|
+
write: false
|
|
904
|
+
};
|
|
905
|
+
applyRuleAction$1(rule, action, statement, this.now());
|
|
906
|
+
return {
|
|
907
|
+
value: true,
|
|
908
|
+
write: true
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
if (!outcome.value) return outcome.trustworthy ? "not-found" : "persist-failed";
|
|
912
|
+
return outcome.ok ? "persisted" : "persist-failed";
|
|
913
|
+
}
|
|
914
|
+
/** /lessons-digest 与卡片手工补录直接建候选(origin 标记来源)。
|
|
915
|
+
* 键与 report() 同源:蒸馏出来的路径型签名同样折叠,否则机器建卡又会开一批碎片。 */
|
|
916
|
+
async addCandidate(input) {
|
|
917
|
+
const now = this.now();
|
|
918
|
+
const rawSignature = normalizeSignature(input.signature);
|
|
919
|
+
const signature = stableSignatureFor(input.category, rawSignature);
|
|
920
|
+
const raw = input.source ?? LESSONS_DIGEST_SOURCE;
|
|
921
|
+
const source = noteSourceFallback(raw, normalizeLessonSource(raw));
|
|
922
|
+
const key = ruleKey(input.project, input.category, signature);
|
|
923
|
+
return (await this.commit((cards) => {
|
|
924
|
+
const existing = findByKey(cards, key);
|
|
925
|
+
if (existing !== void 0) {
|
|
926
|
+
existing.updatedAt = now;
|
|
927
|
+
existing.evidence.push(evidenceRow(now, source, input.detail, rawSignature, signature));
|
|
928
|
+
if (existing.status === "candidate") existing.occurrences += 1;
|
|
929
|
+
return {
|
|
930
|
+
value: existing,
|
|
931
|
+
write: true
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
const card = newRuleCard({
|
|
935
|
+
project: input.project,
|
|
936
|
+
category: input.category,
|
|
937
|
+
signature,
|
|
938
|
+
statement: input.statement,
|
|
939
|
+
origin: LESSONS_DIGEST_SOURCE,
|
|
940
|
+
source,
|
|
941
|
+
lastSeenAt: now,
|
|
942
|
+
evidence: evidenceRow(now, source, input.detail, rawSignature, signature),
|
|
943
|
+
now
|
|
944
|
+
});
|
|
945
|
+
cards.push(card);
|
|
946
|
+
return {
|
|
947
|
+
value: card,
|
|
948
|
+
write: true
|
|
949
|
+
};
|
|
950
|
+
})).value;
|
|
951
|
+
}
|
|
952
|
+
/** 把 source 碎片并入 primary(occurrences/evidence/sources/lastSeenAt 累加;
|
|
953
|
+
* 更晚 armed 的碎片覆盖 primary 的 statement 与生效时间)。 */
|
|
954
|
+
static mergeFragment(primary, source) {
|
|
955
|
+
if (source.status === "armed" && typeof source.armedAt === "number" && source.armedAt > (primary.armedAt ?? 0)) {
|
|
956
|
+
primary.statement = source.statement;
|
|
957
|
+
primary.armedAt = source.armedAt;
|
|
958
|
+
}
|
|
959
|
+
primary.occurrences += source.occurrences;
|
|
960
|
+
primary.evidence.push(...source.evidence);
|
|
961
|
+
primary.sources = [.../* @__PURE__ */ new Set([...primary.sources, ...source.sources])];
|
|
962
|
+
if (source.lastSeenAt !== void 0 && (primary.lastSeenAt === void 0 || source.lastSeenAt > primary.lastSeenAt)) primary.lastSeenAt = source.lastSeenAt;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* 存量碎片迁移(碎片化修复):把 category 对应类别级稳定签名出现
|
|
966
|
+
* 之前的「路径型签名」候选/armed/rejected 规则归并。合并规则:
|
|
967
|
+
* - 候选/armed 碎片并入稳定签名卡(若已存在直接并入;否则取最权威一张——
|
|
968
|
+
* armed 优先、其次 occurrences 最多——改写为稳定签名,statement 保留人工
|
|
969
|
+
* 改写,其余并入并移除);
|
|
970
|
+
* - rejected 碎片并入 `${stableSig}:rejected` 汇总卡(人工拒绝语义保留:
|
|
971
|
+
* 状态 rejected、不复活、不注入;碎片卡从规则列表收敛,避免 20 条同内容
|
|
972
|
+
* 重复占据列表);
|
|
973
|
+
* - archived/demoted 碎片不参与(一律人工动作决定)。
|
|
974
|
+
* 按 project 分组,跨项目绝不合并。
|
|
975
|
+
* 返回并入的碎片数。
|
|
976
|
+
*/
|
|
977
|
+
async migrateFragmentRules() {
|
|
978
|
+
return (await this.commit((cards) => {
|
|
979
|
+
const pass = this.mergeFragmentsIn(cards);
|
|
980
|
+
return {
|
|
981
|
+
value: pass.merged,
|
|
982
|
+
write: pass.dirty
|
|
983
|
+
};
|
|
984
|
+
})).value;
|
|
985
|
+
}
|
|
986
|
+
/** 在待写数组上跑一遍全类别的碎片归并(数组就地收敛;产出见 MergePass)。 */
|
|
987
|
+
mergeFragmentsIn(cards) {
|
|
988
|
+
let merged = 0;
|
|
989
|
+
let dirty = false;
|
|
990
|
+
for (const [category, stableSig] of Object.entries(CATEGORY_SIGNATURES)) {
|
|
991
|
+
const candidates = cards.filter((card) => card.category === category && card.signature !== stableSig && (card.status === "candidate" || card.status === "armed" || card.status === "rejected") && looksLikePathSignature(card.signature));
|
|
992
|
+
const projects = [...new Set(candidates.map((card) => card.project))];
|
|
993
|
+
for (const project of projects) {
|
|
994
|
+
const live = candidates.filter((card) => card.project === project && card.status !== "rejected");
|
|
995
|
+
const rejected = candidates.filter((card) => card.project === project && card.status === "rejected");
|
|
996
|
+
const livePass = this.migrateProjectFragments(cards, live, category, stableSig, project);
|
|
997
|
+
const rejectedPass = this.migrateRejectedFragments(cards, rejected, category, stableSig, project);
|
|
998
|
+
merged += livePass.merged + rejectedPass.merged;
|
|
999
|
+
dirty ||= livePass.dirty || rejectedPass.dirty;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return {
|
|
1003
|
+
merged,
|
|
1004
|
+
dirty
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/** rejected 路径碎片的归并汇总:并入该 project 一条签名 `${stableSig}:rejected`
|
|
1008
|
+
* 的汇总卡(人工拒绝语义保留:状态 rejected、不复活、不注入),避免碎片卡
|
|
1009
|
+
* 占据规则列表。与 armed/candidate 稳定卡不冲突(签名不同)。 */
|
|
1010
|
+
migrateRejectedFragments(cards, fragments, category, stableSig, project) {
|
|
1011
|
+
const summarySig = `${stableSig}:rejected`;
|
|
1012
|
+
const summary = cards.find((card) => card.category === category && card.signature === summarySig && card.project === project);
|
|
1013
|
+
const [busiest] = fragments.toSorted(byOccurrences);
|
|
1014
|
+
const primary = summary ?? busiest;
|
|
1015
|
+
if (primary === void 0) return CLEAN_PASS;
|
|
1016
|
+
let merged = 0;
|
|
1017
|
+
const doomed = /* @__PURE__ */ new Set();
|
|
1018
|
+
for (const card of fragments) if (card.id !== primary.id) {
|
|
1019
|
+
LessonStore.mergeFragment(primary, card);
|
|
1020
|
+
doomed.add(card.id);
|
|
1021
|
+
merged += 1;
|
|
1022
|
+
}
|
|
1023
|
+
let rewritten = false;
|
|
1024
|
+
if (primary.signature !== summarySig) {
|
|
1025
|
+
primary.signature = summarySig;
|
|
1026
|
+
primary.statement = draftStatement(category, summarySig, this.messages());
|
|
1027
|
+
primary.status = "rejected";
|
|
1028
|
+
rewritten = true;
|
|
1029
|
+
}
|
|
1030
|
+
if (merged > 0 || rewritten) primary.updatedAt = this.now();
|
|
1031
|
+
dropCards(cards, doomed);
|
|
1032
|
+
return {
|
|
1033
|
+
merged,
|
|
1034
|
+
dirty: merged > 0 || rewritten
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/** 单个 project 的候选/armed 碎片归并(产出见 MergePass)。 */
|
|
1038
|
+
migrateProjectFragments(cards, fragments, category, stableSig, project) {
|
|
1039
|
+
const existing = cards.find((card) => card.category === category && card.signature === stableSig && card.project === project);
|
|
1040
|
+
const armedFirst = fragments.find((card) => card.status === "armed");
|
|
1041
|
+
const [busiest] = fragments.toSorted(byOccurrences);
|
|
1042
|
+
const primary = existing ?? armedFirst ?? busiest;
|
|
1043
|
+
if (primary === void 0) return CLEAN_PASS;
|
|
1044
|
+
let merged = 0;
|
|
1045
|
+
let armedSource;
|
|
1046
|
+
const doomed = /* @__PURE__ */ new Set();
|
|
1047
|
+
for (const card of fragments) if (card.id !== primary.id) {
|
|
1048
|
+
if (card.status === "armed" && armedSource === void 0) armedSource = card;
|
|
1049
|
+
LessonStore.mergeFragment(primary, card);
|
|
1050
|
+
doomed.add(card.id);
|
|
1051
|
+
merged += 1;
|
|
1052
|
+
}
|
|
1053
|
+
let rewritten = false;
|
|
1054
|
+
if (primary.signature !== stableSig) {
|
|
1055
|
+
primary.signature = stableSig;
|
|
1056
|
+
rewritten = true;
|
|
1057
|
+
}
|
|
1058
|
+
if (primary.status !== "armed" && armedSource !== void 0) LessonStore.inheritArmed(primary, armedSource);
|
|
1059
|
+
if (merged > 0 || rewritten) primary.updatedAt = this.now();
|
|
1060
|
+
dropCards(cards, doomed);
|
|
1061
|
+
return {
|
|
1062
|
+
merged,
|
|
1063
|
+
dirty: merged > 0 || rewritten
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
/** 把 armed 碎片的生命周期度量搬到主卡(arm 期计数属于那次升格,不能留空)。
|
|
1067
|
+
* 三个时间戳按静态键逐个处理:源卡缺值时必须真的删键(留 undefined 与删键在
|
|
1068
|
+
* JSON 往返后语义不同,decayVerdict 的锚点回退链会被 undefined 干扰)。 */
|
|
1069
|
+
static inheritArmed(primary, armedSource) {
|
|
1070
|
+
primary.status = "armed";
|
|
1071
|
+
primary.violation = armedSource.violation;
|
|
1072
|
+
primary.suppressed = armedSource.suppressed;
|
|
1073
|
+
primary.samples = armedSource.samples;
|
|
1074
|
+
if (armedSource.armedAt === void 0) delete primary.armedAt;
|
|
1075
|
+
else primary.armedAt = armedSource.armedAt;
|
|
1076
|
+
if (armedSource.lastViolationAt === void 0) delete primary.lastViolationAt;
|
|
1077
|
+
else primary.lastViolationAt = armedSource.lastViolationAt;
|
|
1078
|
+
if (armedSource.lastSuppressedAt === void 0) delete primary.lastSuppressedAt;
|
|
1079
|
+
else primary.lastSuppressedAt = armedSource.lastSuppressedAt;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* 一次性 project 键归一迁移:桶键派生已收敛到 shared/lib/project-key.ts(补
|
|
1083
|
+
* path.resolve + realpath)。旧键是"纯字符串归一"的产物——cwd 写成相对路径、含
|
|
1084
|
+
* `..` 冗余段、或经过软链(macOS /tmp ↔ /private/tmp)时,同一个项目会被切成
|
|
1085
|
+
* 两个桶:教训与 armed 规则注入各看到一半。
|
|
1086
|
+
*
|
|
1087
|
+
* 迁移只按卡片**自己存的 cwd** 重算:
|
|
1088
|
+
* - 没存 cwd 的存量卡原样留下。project 是跨系统(记忆网关 agent_id)的桶名,
|
|
1089
|
+
* 猜错一次就把别的项目的规则注进当前会话,宁可不迁;
|
|
1090
|
+
* - 重算后与别的卡撞同 (project, category, signature) 键 → 归并,主卡由人工
|
|
1091
|
+
* 状态决定(见 pickMergePair),证据/观察次数按 mergeFragment 折叠;
|
|
1092
|
+
* - 幂等:第二次跑每张卡的 project 都已等于 deriveProjectKey(cwd) → 零改动、
|
|
1093
|
+
* 零落盘(不会与 migrateFragmentRules 互相回退)。
|
|
1094
|
+
* 已规范绝对路径的输入产出的键与旧实现逐字节相同,所以"正常机器"上这里是 no-op。
|
|
1095
|
+
*/
|
|
1096
|
+
async migrateProjectKeys() {
|
|
1097
|
+
return (await this.commit((cards) => {
|
|
1098
|
+
let rewrites = 0;
|
|
1099
|
+
for (const card of cards) {
|
|
1100
|
+
const cwd = nonEmptyString(card.cwd);
|
|
1101
|
+
if (cwd !== void 0) {
|
|
1102
|
+
const target = deriveProjectKey(cwd);
|
|
1103
|
+
if (target !== card.project) {
|
|
1104
|
+
card.project = target;
|
|
1105
|
+
rewrites += 1;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (rewrites === 0) return {
|
|
1110
|
+
value: {
|
|
1111
|
+
rewrites: 0,
|
|
1112
|
+
merged: 0
|
|
1113
|
+
},
|
|
1114
|
+
write: false
|
|
1115
|
+
};
|
|
1116
|
+
return {
|
|
1117
|
+
value: {
|
|
1118
|
+
rewrites,
|
|
1119
|
+
merged: this.mergeProjectKeyCollisions(cards)
|
|
1120
|
+
},
|
|
1121
|
+
write: true
|
|
1122
|
+
};
|
|
1123
|
+
})).value;
|
|
1124
|
+
}
|
|
1125
|
+
/** project 重算后按 (project, category, signature) 键归并撞车的卡,返回被并掉的张数。
|
|
1126
|
+
* 顺带守住"一键一卡"不变式:report()/addCandidate() 都按这个键查找,库里同键留两张
|
|
1127
|
+
* 等于第二张永远查不到(观测堆在没人看的卡上)。 */
|
|
1128
|
+
mergeProjectKeyCollisions(cards) {
|
|
1129
|
+
const survivors = /* @__PURE__ */ new Map();
|
|
1130
|
+
const doomed = /* @__PURE__ */ new Set();
|
|
1131
|
+
const now = this.now();
|
|
1132
|
+
let merged = 0;
|
|
1133
|
+
for (const card of cards) {
|
|
1134
|
+
const key = ruleKey(card.project, card.category, card.signature);
|
|
1135
|
+
const kept = survivors.get(key);
|
|
1136
|
+
if (kept === void 0) survivors.set(key, card);
|
|
1137
|
+
else {
|
|
1138
|
+
const [primary, extra] = pickMergePair(kept, card);
|
|
1139
|
+
LessonStore.mergeFragment(primary, extra);
|
|
1140
|
+
primary.updatedAt = now;
|
|
1141
|
+
survivors.set(key, primary);
|
|
1142
|
+
doomed.add(primary === kept ? extra.id : kept.id);
|
|
1143
|
+
merged += 1;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
dropCards(cards, doomed);
|
|
1147
|
+
return merged;
|
|
1148
|
+
}
|
|
1149
|
+
/** 该规则当前是否"不可判定"(armed 够久却 violation+suppressed 双零)。派生态,
|
|
1150
|
+
* 每次按当前 policy/now 现算,不落库——规则重新被触发(violation 或 pass 计数)
|
|
1151
|
+
* 即自然退出该态。 */
|
|
1152
|
+
isUndeterminable(rule) {
|
|
1153
|
+
return decayVerdict(rule, this.policy(), this.now()) === "undeterminable";
|
|
1154
|
+
}
|
|
1155
|
+
/** 全部"不可判定"的 armed 规则(供 /stats 汇总与卡片提示;人工可停用/归档)。 */
|
|
1156
|
+
undeterminableRules() {
|
|
1157
|
+
return this.rules().filter((rule) => this.isUndeterminable(rule));
|
|
1158
|
+
}
|
|
1159
|
+
/** 周期衰减:对全部 armed 规则执行裁决。降级会改状态并落盘;"不可判定"只是派生
|
|
1160
|
+
* 提示态(状态仍 armed,等人处理),不改盘。auto-archive 已被不可判定取代。 */
|
|
1161
|
+
async runDecay() {
|
|
1162
|
+
const now = this.now();
|
|
1163
|
+
const policy = this.policy();
|
|
1164
|
+
const outcome = await this.commit((cards) => {
|
|
1165
|
+
let demoted = 0;
|
|
1166
|
+
let undeterminable = 0;
|
|
1167
|
+
for (const rule of cards) {
|
|
1168
|
+
const verdict = decayVerdict(rule, policy, now);
|
|
1169
|
+
if (verdict === "demote") {
|
|
1170
|
+
rule.status = "demoted";
|
|
1171
|
+
rule.updatedAt = now;
|
|
1172
|
+
demoted += 1;
|
|
1173
|
+
} else if (verdict === "undeterminable") undeterminable += 1;
|
|
1174
|
+
}
|
|
1175
|
+
return {
|
|
1176
|
+
value: {
|
|
1177
|
+
demoted,
|
|
1178
|
+
undeterminable
|
|
1179
|
+
},
|
|
1180
|
+
write: demoted > 0
|
|
1181
|
+
};
|
|
1182
|
+
});
|
|
1183
|
+
const { demoted, undeterminable } = outcome.value;
|
|
1184
|
+
if (demoted + undeterminable > 0) console.info(`[lesson-loop] decay: ${demoted} demoted, ${undeterminable} undeterminable`);
|
|
1185
|
+
return outcome.value;
|
|
1186
|
+
}
|
|
1187
|
+
/** 近期教训(project 过滤可选;limit ≤ 0 = 全量——内容零截断)。 */
|
|
1188
|
+
recentLessons(project, limit = 0) {
|
|
1189
|
+
const out = [];
|
|
1190
|
+
for (const row of readJsonl(this.opts.lessonsFile)) {
|
|
1191
|
+
const lesson = normalizeLessonRow(row);
|
|
1192
|
+
if (lesson !== null && (project === void 0 || lesson.project === project)) out.push(lesson);
|
|
1193
|
+
}
|
|
1194
|
+
return limit > 0 ? out.slice(-limit) : out;
|
|
1195
|
+
}
|
|
1196
|
+
lessonsCount() {
|
|
1197
|
+
return readJsonl(this.opts.lessonsFile).length;
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
//#endregion
|
|
1201
|
+
//#region lib/prompt.ts
|
|
1202
|
+
/** systemPrompt 常驻段(order 1560:zvec-grep routing 1550 之后,紧贴工具规则面)。 */
|
|
1203
|
+
const SECTION_NAME = "lesson-loop";
|
|
1204
|
+
const SECTION_ORDER = 1560;
|
|
1205
|
+
/**
|
|
1206
|
+
* 本插件的身份串——**全包唯一定义**:host.ts 从这里导入(服务名 / 开关命名空间
|
|
1207
|
+
* `lesson-loop` / cache 子目录 / 注入消息 id 前缀都用它),不再自带第二份。
|
|
1208
|
+
* 上方 SECTION_NAME 是同一插件的另一面(systemPrompt 段的命名),值相同但语义
|
|
1209
|
+
* 独立,两条各有测试钉住(test/prompt.test.ts、test/host.test.ts)。
|
|
1210
|
+
* client 半的 `src/client-entry.ts` 另有一份 `NS`:那是独立打包的另一半,吃不到 host lib。
|
|
1211
|
+
*/
|
|
1212
|
+
const PLUGIN_NAME = "lesson-loop";
|
|
1213
|
+
/**
|
|
1214
|
+
* 本插件注入消息的 producer-owned source.kind——**全包唯一定义**:host 的 armed
|
|
1215
|
+
* 规则摘要注入(agent.inject)与 lib/digest.ts 的蒸馏请求帧都从这里导入。此前两侧
|
|
1216
|
+
* 各写一遍同值常量(同一生产者两份身份声明),改一处漏一处就会让"注入时写的 kind"
|
|
1217
|
+
* 与"读回时比的 kind"分家。
|
|
1218
|
+
*
|
|
1219
|
+
* 0.1.7 的 V4 准入拒收退役包装 `{ kind: 'plugin', plugin }`(session-format-v3-to-v4
|
|
1220
|
+
* 的 message-sources.ts 对每个声明的持久消息位抛 "format v4 message requires a
|
|
1221
|
+
* producer-owned source kind";agent.inject 经 inbox 落 `agent/inbox/spliced`,正是
|
|
1222
|
+
* 被拒的持久位),而迁移表给未知插件名统一加 `plugin:` 前缀(sources.ts
|
|
1223
|
+
* producerKind),故本插件已迁移的历史行读回也是这个串。`form` 属于本插件自己的
|
|
1224
|
+
* 元数据,迁移只改写身份字段,它原样保留。
|
|
1225
|
+
*/
|
|
1226
|
+
const LESSON_SOURCE_KIND = `plugin:${PLUGIN_NAME}`;
|
|
1227
|
+
/** 常驻段正文(取自消息表;关闭时段文本由 host 侧返空串)。 */
|
|
1228
|
+
function renderSectionText(messages) {
|
|
1229
|
+
return messages.sectionText;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* 不可信正文的引用标记:每行行前置一个帧本身永不使用的字符。
|
|
1233
|
+
*
|
|
1234
|
+
* 定界的可证性就来自这一点——帧的结构符(`[lesson-loop]` 抬头、`1.` 编号行、
|
|
1235
|
+
* `##` 分节、`-` 背景条目)都在行首,而经 fenceUntrusted 的正文行一律以引用标记
|
|
1236
|
+
* 开头,故「行首结构符」只能由帧自己产生;正文再夹带结构符会连同其自带的引用
|
|
1237
|
+
* 一起被改写(见 STRUCTURAL_LEAD),最坏是双标记行,永远撞不上单标记的帧行。
|
|
1238
|
+
*/
|
|
1239
|
+
const QUOTE_MARK = "│ ";
|
|
1240
|
+
/** 行首结构符:编号(1. / 1)/ 1、)、分隔线、标题、标签、列表与引用符。 */
|
|
1241
|
+
const STRUCTURAL_LEAD = /^[│\s]*(?:\d{1,9}[.)、]|-{3,}|\*{3,}|_{3,}|={3,}|#{1,6}|\[lesson-loop\]|[-*+>](?=[ \t]))/iu;
|
|
1242
|
+
/** 改写行首结构符:整段用全角方括号包住(只增不减,绝不截断正文)。 */
|
|
1243
|
+
function defuseLead(line) {
|
|
1244
|
+
return line.replace(STRUCTURAL_LEAD, (matched) => `【${matched}】`);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* 把不可信文本嵌入注入帧/蒸馏提示前的定界改写(零截断:只做前缀与包括号,
|
|
1248
|
+
* 原文字符一个不丢)。逐行加引用标记 + 改写行首结构符,双向保证嵌入内容
|
|
1249
|
+
* 伪造不出新的编号条目、分节标题或分隔线。
|
|
1250
|
+
*/
|
|
1251
|
+
function fenceUntrusted(text) {
|
|
1252
|
+
return text.split("\n").map((line) => `${QUOTE_MARK}${defuseLead(line)}`).join("\n");
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* armed 规则的度量尾巴:三个计数器语义不同——复发/被遵守是"测到过"的真实证据,
|
|
1256
|
+
* 暴露只是"在场"。从没被测到(复发与被遵守皆零)时明说"尚无证据",而不是伪造百分比。
|
|
1257
|
+
* 双零(连暴露都没有)不拼任何尾巴。无 armed 摘要以外的调用者,故不导出。
|
|
1258
|
+
*/
|
|
1259
|
+
function armedMetric(messages, rule) {
|
|
1260
|
+
if (rule.violation + rule.suppressed > 0) return fill(messages.armedMetrics, {
|
|
1261
|
+
violation: rule.violation,
|
|
1262
|
+
suppressed: rule.suppressed,
|
|
1263
|
+
samples: rule.samples
|
|
1264
|
+
});
|
|
1265
|
+
if (rule.samples > 0) return fill(messages.armedNoTrigger, { samples: rule.samples });
|
|
1266
|
+
return "";
|
|
1267
|
+
}
|
|
1268
|
+
/** 会话开始注入的 armed 规则摘要。无 armed 规则返回 null(零噪音,不注入空块)。 */
|
|
1269
|
+
function renderRulesDigest(rules, project, messages) {
|
|
1270
|
+
const armed = rules.filter((rule) => rule.status === "armed" && rule.project === project).toSorted((left, right) => (left.armedAt ?? 0) - (right.armedAt ?? 0));
|
|
1271
|
+
if (armed.length === 0) return null;
|
|
1272
|
+
const lines = [messages.digestHeading];
|
|
1273
|
+
for (const [index, rule] of armed.entries()) {
|
|
1274
|
+
const armedDay = rule.armedAt === void 0 ? "?" : new Date(rule.armedAt).toISOString().slice(0, 10);
|
|
1275
|
+
lines.push(fill(messages.digestRuleLine, {
|
|
1276
|
+
index: index + 1,
|
|
1277
|
+
statement: fenceUntrusted(rule.statement),
|
|
1278
|
+
day: armedDay,
|
|
1279
|
+
metrics: armedMetric(messages, rule)
|
|
1280
|
+
}));
|
|
1281
|
+
}
|
|
1282
|
+
return lines.join("\n");
|
|
1283
|
+
}
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region lib/rules-layout.ts
|
|
1286
|
+
/**
|
|
1287
|
+
* 规则库存身的设置命名空间 = 本包 profile **条目 id**(`lesson-loop`,见 cordis.patch.yml
|
|
1288
|
+
* 的裸 `- id:`)。0.1.7 的注册是隐式的:宿主按条目导出的 `Config` 反推命名空间与可编辑
|
|
1289
|
+
* 字段,插件侧不再登记任何段。与 lib/prompt.ts 的 PLUGIN_NAME 同源,防"卡片绑的段 /
|
|
1290
|
+
* 端点前缀 / 这里读的段"三处字面量漂移。
|
|
1291
|
+
* 类型标注不是装饰:它把"段名就是插件名那一枚字面量"钉成类型,PLUGIN_NAME 若从
|
|
1292
|
+
* `"lesson-loop"` 变成 `string`,这里当场编译不过(段名散成宽类型正是漂移的开始)。
|
|
1293
|
+
*/
|
|
1294
|
+
const SETTINGS_NAMESPACE = PLUGIN_NAME;
|
|
1295
|
+
/** 规则数组在 `Config` 里的字段名(读写 `describe()` 的 value/user 都以它为键)。 */
|
|
1296
|
+
const RULES_FIELD = "rules";
|
|
1297
|
+
/** 解析后的命名空间值 → 规则卡数组(逐行归一;非数组一律空库)。 */
|
|
1298
|
+
function cardsOfValue(value) {
|
|
1299
|
+
const out = [];
|
|
1300
|
+
for (const row of unknownArray$1(fieldOf(value, RULES_FIELD))) {
|
|
1301
|
+
const card = normalizeRuleCardRow(row);
|
|
1302
|
+
if (card !== null) out.push(card);
|
|
1303
|
+
}
|
|
1304
|
+
return out;
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
1307
|
+
//#region lib/rules-namespace.ts
|
|
1308
|
+
/**
|
|
1309
|
+
* 单字段声明:类型判定**不在这里**发生。
|
|
1310
|
+
*
|
|
1311
|
+
* 收窄由 lib/lesson-store.ts 的 normalizeRuleCardRow 逐字段负责(既有口径:一条
|
|
1312
|
+
* 漂移记录不许拖垮整条总线)。schema 若把字段声明成强类型,用户手改一个
|
|
1313
|
+
* `occurrences: "3"` 就会让整片规则库读不出来——比旧实现更差的退化,故这里只
|
|
1314
|
+
* 声明"有哪些字段"(给配置面与 describe().schema 看),判定留给读取侧。
|
|
1315
|
+
*/
|
|
1316
|
+
const cardField = Schema.any();
|
|
1317
|
+
/** 规则卡的形状声明(与 lib/lesson-store.ts 的 RuleCard 字段一一对应)。 */
|
|
1318
|
+
const RuleCardSchema = Schema.object({
|
|
1319
|
+
id: cardField,
|
|
1320
|
+
project: cardField,
|
|
1321
|
+
cwd: cardField,
|
|
1322
|
+
category: cardField,
|
|
1323
|
+
signature: cardField,
|
|
1324
|
+
statement: cardField,
|
|
1325
|
+
status: cardField,
|
|
1326
|
+
createdAt: cardField,
|
|
1327
|
+
updatedAt: cardField,
|
|
1328
|
+
armedAt: cardField,
|
|
1329
|
+
occurrences: cardField,
|
|
1330
|
+
sources: cardField,
|
|
1331
|
+
violation: cardField,
|
|
1332
|
+
suppressed: cardField,
|
|
1333
|
+
samples: cardField,
|
|
1334
|
+
recurrences: cardField,
|
|
1335
|
+
lastSeenAt: cardField,
|
|
1336
|
+
lastViolationAt: cardField,
|
|
1337
|
+
lastSuppressedAt: cardField,
|
|
1338
|
+
evidence: cardField,
|
|
1339
|
+
origin: cardField
|
|
1340
|
+
});
|
|
1341
|
+
/**
|
|
1342
|
+
* 规则库字段:本条目 `Config` 里的一个 volatile 数组(空库 = `[]`)。
|
|
1343
|
+
*
|
|
1344
|
+
* 三段各有理由,改之前逐段读:
|
|
1345
|
+
* - `.volatile()`:0.1.7 只有标了 volatile 的字段会被宿主投影成设置表单并允许写入
|
|
1346
|
+
* (packages/settings/settings/src/schema.ts:37-47 + index.ts:308-309/386)。规则库
|
|
1347
|
+
* 要活读写,这一层不能省;也因此**没有**"注册被打回"这条路了——条目 Config 的校验
|
|
1348
|
+
* 发生在 cordis 装载期(vendor/cordis/src/fiber.ts:50-61 resolveConfig 抛
|
|
1349
|
+
* ValidationError → :645-668 整个条目不执行),插件侧无处 catch。
|
|
1350
|
+
* - `.loose()`:正是为了接住上一条。用户把 `rules` 手改成非数组(`rules: 乱码`)时,
|
|
1351
|
+
* 数组类型判定会抛——**同段**的开关连同整条总线会跟着这个条目一起不加载。`.loose()`
|
|
1352
|
+
* 让子树判定失败退到该字段的默认值(vendor/schemastery/src/index.ts:551),条目照常
|
|
1353
|
+
* 装载,坏值只影响规则库自己。"读不懂"另有判定,见 load()。
|
|
1354
|
+
* - `.default([])`:0.1.7 没有 base 层,原 RULES_BASE 的 `{ rules: [] }` 就落在这里。
|
|
1355
|
+
* 数组是**整片覆盖**(mergeLayers 不数组合并),所以默认值必须是完整值——空库的完整
|
|
1356
|
+
* 值就是 `[]`。schemastery 给 array 预置的 meta.default 本来就是 `[]`,显式写出是为了
|
|
1357
|
+
* 让"底座 → 默认"这条映射在 schema 上看得见。
|
|
1358
|
+
*/
|
|
1359
|
+
const RulesFieldSchema = Schema.array(Schema.union([RuleCardSchema, cardField])).loose().default([]).volatile();
|
|
1360
|
+
/**
|
|
1361
|
+
* SettingsConflictError 的判定:不值导入 @deepseek-ai/*(本插件只 type-only 用它),
|
|
1362
|
+
* 按官方类的两个稳定成员认(`code === 'SETTINGS_CONFLICT'` 是给人映射的机器码,
|
|
1363
|
+
* `name` 是类名兜底)。认出来才谈重试——其它错误重试也不会变好。
|
|
1364
|
+
*/
|
|
1365
|
+
function isSettingsConflict(error) {
|
|
1366
|
+
return fieldOf(error, "code") === "SETTINGS_CONFLICT" || fieldOf(error, "name") === "SettingsConflictError";
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* user 层里的 `rules` 键是不是"人改坏的形状"(存在、但不是数组)。
|
|
1370
|
+
*
|
|
1371
|
+
* 这就是 0.1.7 版的"注册被存量坏数据打回"判定:`.loose()` 让 cordis 把坏值解析成 `[]`,
|
|
1372
|
+
* 于是 value 层看着完全合法——只盯 value 就会把"我读不懂"当成"别人确实没写过",下一次
|
|
1373
|
+
* 提交顺手把整段(连人还没修好的那部分一起)覆掉。`describe().user` 是文档原文那一层
|
|
1374
|
+
* (宿主用 projectForm(form, override) 投出来,只丢 undefined、不做类型修正),坏值在
|
|
1375
|
+
* 那里现形。修好之后这条判定自然放行,不必重启(热加载)。
|
|
1376
|
+
*/
|
|
1377
|
+
function rulesCorrupted(user) {
|
|
1378
|
+
const raw = fieldOf(user, RULES_FIELD);
|
|
1379
|
+
return raw !== void 0 && !Array.isArray(raw);
|
|
1380
|
+
}
|
|
1381
|
+
/** 不可用回执:读返回空、写一律拒绝(调用方按 persist-failed 语义处理)。 */
|
|
1382
|
+
const UNUSABLE_READ = {
|
|
1383
|
+
revision: 0,
|
|
1384
|
+
cards: [],
|
|
1385
|
+
usable: false
|
|
1386
|
+
};
|
|
1387
|
+
/** 没有可用 CAS 面(provider 缺位 / 该段读不出可信值)时的规则库端口。 */
|
|
1388
|
+
function unavailableRulesRepository() {
|
|
1389
|
+
return {
|
|
1390
|
+
load: () => UNUSABLE_READ,
|
|
1391
|
+
save: () => Promise.resolve("rejected")
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* 建规则库端口。
|
|
1396
|
+
* @param surface provider 面;null = 该命名空间不可用(宿主没有 CAS 面)。
|
|
1397
|
+
*/
|
|
1398
|
+
function createRulesRepository(surface) {
|
|
1399
|
+
if (surface === null) return unavailableRulesRepository();
|
|
1400
|
+
const findDescriptor = () => {
|
|
1401
|
+
const found = surface.describe().find((item) => fieldOf(item, "ns") === SETTINGS_NAMESPACE);
|
|
1402
|
+
if (found === void 0) return null;
|
|
1403
|
+
const revision = fieldOf(found, "revision");
|
|
1404
|
+
return {
|
|
1405
|
+
value: fieldOf(found, "value"),
|
|
1406
|
+
user: fieldOf(found, "user"),
|
|
1407
|
+
revision: typeof revision === "number" ? revision : 0
|
|
1408
|
+
};
|
|
1409
|
+
};
|
|
1410
|
+
let corruptReported = false;
|
|
1411
|
+
const reportCorruption = (corrupt) => {
|
|
1412
|
+
if (corrupt && !corruptReported) {
|
|
1413
|
+
corruptReported = true;
|
|
1414
|
+
console.error(`[lesson-loop] rules namespace rejected: "${SETTINGS_NAMESPACE}.rules" is not an array — rule library reads empty and refuses writes (switches still work)`);
|
|
1415
|
+
} else if (!corrupt) corruptReported = false;
|
|
1416
|
+
};
|
|
1417
|
+
return {
|
|
1418
|
+
load() {
|
|
1419
|
+
try {
|
|
1420
|
+
const found = findDescriptor();
|
|
1421
|
+
const corrupt = found !== null && rulesCorrupted(found.user);
|
|
1422
|
+
reportCorruption(corrupt);
|
|
1423
|
+
return found === null || corrupt ? UNUSABLE_READ : {
|
|
1424
|
+
revision: found.revision,
|
|
1425
|
+
cards: cardsOfValue(found.value),
|
|
1426
|
+
usable: true
|
|
1427
|
+
};
|
|
1428
|
+
} catch (error) {
|
|
1429
|
+
console.error(`[lesson-loop] rules read failed: ${errorText(error)}`);
|
|
1430
|
+
return UNUSABLE_READ;
|
|
1431
|
+
}
|
|
1432
|
+
},
|
|
1433
|
+
async save(cards, revision) {
|
|
1434
|
+
try {
|
|
1435
|
+
await surface.update(SETTINGS_NAMESPACE, { [RULES_FIELD]: [...cards] }, revision);
|
|
1436
|
+
return "persisted";
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
if (isSettingsConflict(error)) return "conflict";
|
|
1439
|
+
console.error(`[lesson-loop] rules write failed: ${errorText(error)}`);
|
|
1440
|
+
return "rejected";
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region lib/negative-feedback.ts
|
|
1447
|
+
/** 消息差评投影:put 载荷即改后的完整当前值,字段漂移按空串/缺省归一。 */
|
|
1448
|
+
function messageFeedback(item) {
|
|
1449
|
+
const messageId = fieldOf(item, "messageId");
|
|
1450
|
+
const note = fieldOf(item, "note");
|
|
1451
|
+
const category = fieldOf(item, "category");
|
|
1452
|
+
return {
|
|
1453
|
+
...typeof messageId === "string" ? { messageId } : {},
|
|
1454
|
+
note: typeof note === "string" ? note : "",
|
|
1455
|
+
...typeof category === "string" ? { category } : {},
|
|
1456
|
+
kind: "message"
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
/** 占用(或复用)某条消息的槽位;新槽位按出现顺序追加。 */
|
|
1460
|
+
function slotFor(ledger, messageId) {
|
|
1461
|
+
const existing = ledger.keyed.get(messageId);
|
|
1462
|
+
if (existing !== void 0) return existing;
|
|
1463
|
+
const slot = { feedback: void 0 };
|
|
1464
|
+
ledger.keyed.set(messageId, slot);
|
|
1465
|
+
ledger.ordered.push(slot);
|
|
1466
|
+
return slot;
|
|
1467
|
+
}
|
|
1468
|
+
/** feedback/message-put:create-or-edit,按 messageId 折叠到当前态。 */
|
|
1469
|
+
function applyMessagePut(data, ledger, negative) {
|
|
1470
|
+
const item = fieldOf(data, "item");
|
|
1471
|
+
const messageId = fieldOf(item, "messageId");
|
|
1472
|
+
if (typeof messageId !== "string") {
|
|
1473
|
+
if (negative) ledger.ordered.push({ feedback: messageFeedback(item) });
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const slot = slotFor(ledger, messageId);
|
|
1477
|
+
slot.feedback = negative ? messageFeedback(item) : void 0;
|
|
1478
|
+
}
|
|
1479
|
+
/** feedback/message-delete:撤回一条差评(未记账过的 messageId 与无 id 都无害)。 */
|
|
1480
|
+
function applyMessageDelete(data, ledger) {
|
|
1481
|
+
const messageId = fieldOf(data, "messageId");
|
|
1482
|
+
if (typeof messageId !== "string") return;
|
|
1483
|
+
const slot = ledger.keyed.get(messageId);
|
|
1484
|
+
if (slot !== void 0) slot.feedback = void 0;
|
|
1485
|
+
}
|
|
1486
|
+
/** feedback/record:会话级备注(无 rating 语义也无撤回面,人工提交原文全量纳入)。 */
|
|
1487
|
+
function appendSessionRecord(data, ledger) {
|
|
1488
|
+
const text = fieldOf(data, "text");
|
|
1489
|
+
const category = fieldOf(data, "category");
|
|
1490
|
+
const textStr = typeof text === "string" ? text : "";
|
|
1491
|
+
const catStr = typeof category === "string" ? category : void 0;
|
|
1492
|
+
if (textStr.trim().length === 0 && catStr === void 0) return;
|
|
1493
|
+
ledger.ordered.push({ feedback: {
|
|
1494
|
+
note: textStr,
|
|
1495
|
+
...catStr === void 0 ? {} : { category: catStr },
|
|
1496
|
+
kind: "session"
|
|
1497
|
+
} });
|
|
1498
|
+
}
|
|
1499
|
+
/** 单个事件对槽位台账的作用(非对象事件与未知类型都无副作用)。 */
|
|
1500
|
+
function applyFeedbackEvent(event, ledger) {
|
|
1501
|
+
const evType = fieldOf(event, "type");
|
|
1502
|
+
const evData = fieldOf(event, "data");
|
|
1503
|
+
if (evType === "feedback/message-put") {
|
|
1504
|
+
applyMessagePut(evData, ledger, fieldOf(fieldOf(evData, "item"), "rating") === "negative");
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (evType === "feedback/message-delete") {
|
|
1508
|
+
applyMessageDelete(evData, ledger);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (evType === "feedback/record") appendSessionRecord(evData, ledger);
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* 从会话事件流收集人工差评(negative 评级 + 会话级备注)。全量保留 note 原文。
|
|
1515
|
+
*
|
|
1516
|
+
* `feedback/message-put` 是 create-or-edit(载荷即改后的完整当前值),
|
|
1517
|
+
* `feedback/message-delete` 是撤回(harness message-feedback/types.ts)。故必须按
|
|
1518
|
+
* messageId 折叠到当前态:只追加会把「差评改成好评」「差评已删除」继续当作负面
|
|
1519
|
+
* 信号喂给蒸馏模型,蒸馏结果再进规则卡注入到后续每个会话。
|
|
1520
|
+
*/
|
|
1521
|
+
function collectNegativeFeedback(events) {
|
|
1522
|
+
const ledger = {
|
|
1523
|
+
ordered: [],
|
|
1524
|
+
keyed: /* @__PURE__ */ new Map()
|
|
1525
|
+
};
|
|
1526
|
+
for (const event of events) applyFeedbackEvent(event, ledger);
|
|
1527
|
+
const out = [];
|
|
1528
|
+
for (const slot of ledger.ordered) {
|
|
1529
|
+
const { feedback } = slot;
|
|
1530
|
+
if (feedback !== void 0) out.push(feedback);
|
|
1531
|
+
}
|
|
1532
|
+
return out;
|
|
1533
|
+
}
|
|
1534
|
+
//#endregion
|
|
1535
|
+
//#region lib/digest-prompt.ts
|
|
1536
|
+
/** 近期教训去重压背景(同 signature 只留最新一条 detail;全量文本)。 */
|
|
1537
|
+
function lessonsBackground(store, project, messages, limit = 40) {
|
|
1538
|
+
const rows = store.recentLessons(project, limit);
|
|
1539
|
+
if (rows.length === 0) return messages.lessonsNoHistory;
|
|
1540
|
+
const bySig = /* @__PURE__ */ new Map();
|
|
1541
|
+
for (const row of rows) {
|
|
1542
|
+
const sig = row.signature;
|
|
1543
|
+
const prev = bySig.get(sig);
|
|
1544
|
+
if (prev === void 0 || row.ts > prev.ts) bySig.set(sig, {
|
|
1545
|
+
category: row.category,
|
|
1546
|
+
signature: sig,
|
|
1547
|
+
detail: row.detail,
|
|
1548
|
+
ts: row.ts,
|
|
1549
|
+
source: row.source
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
return [...bySig.values()].toSorted((left, right) => right.ts - left.ts).map((row) => `- [${fenceUntrusted(row.source)}/${fenceUntrusted(row.category)}] ${fenceUntrusted(row.signature)}\n ${fenceUntrusted(row.detail)}`).join("\n");
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* 蒸馏 system 提示里的输出契约行:JSON 键名与 category 取值域是**机器读的数据**
|
|
1556
|
+
* (取值域就是 `lesson-loop` 段 rules 里的归类键,翻译它等于改存储契约),故留在这里;
|
|
1557
|
+
* 只有两处 `<>` 里的说明走消息表。整行的两语骨架逐字节一致,见 digest.test.ts 的钉。
|
|
1558
|
+
*/
|
|
1559
|
+
const DIGEST_OUTPUT_SHAPE = "{\"category\":\"<factgate-deny|dangerous-bash|secret-path|gate-failure|transient-failure|max-tokens|unfinished-turn|feedback-digest>\",\"signature\":\"<{signature}>\",\"statement\":\"<{statement}>\"}";
|
|
1560
|
+
/** 输出契约行(数据骨架 + 消息表的两段说明)。 */
|
|
1561
|
+
function digestOutputShape(messages) {
|
|
1562
|
+
return fill(DIGEST_OUTPUT_SHAPE, {
|
|
1563
|
+
signature: messages.digestSystemShapeSignature,
|
|
1564
|
+
statement: messages.digestSystemShapeStatement
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
/** 蒸馏 system 提示全文(三段来自消息表 → 输出契约行 → 一段来自消息表)。 */
|
|
1568
|
+
function digestSystemPrompt(messages) {
|
|
1569
|
+
return [
|
|
1570
|
+
messages.digestSystemRole,
|
|
1571
|
+
messages.digestSystemShape,
|
|
1572
|
+
digestOutputShape(messages),
|
|
1573
|
+
messages.digestSystemRules
|
|
1574
|
+
].join("\n");
|
|
1575
|
+
}
|
|
1576
|
+
function buildDigestPrompt(feedbacks, background, messages, extra) {
|
|
1577
|
+
const fb = feedbacks.map((entry, index) => {
|
|
1578
|
+
const cat = entry.category === void 0 ? "" : fill(messages.digestCategorySuffix, { category: fenceUntrusted(entry.category) });
|
|
1579
|
+
const scope = entry.kind === "session" ? messages.digestScopeSession : messages.digestScopeMessage;
|
|
1580
|
+
return `${index + 1}. ${scope}${cat} ${fenceUntrusted(entry.note)}`;
|
|
1581
|
+
}).join("\n");
|
|
1582
|
+
return [
|
|
1583
|
+
messages.digestSectionFeedback,
|
|
1584
|
+
fb.length > 0 ? fb : messages.digestNoFeedback,
|
|
1585
|
+
"",
|
|
1586
|
+
messages.digestSectionBackground,
|
|
1587
|
+
background,
|
|
1588
|
+
...extra !== void 0 && extra.trim().length > 0 ? [
|
|
1589
|
+
"",
|
|
1590
|
+
messages.digestSectionExtra,
|
|
1591
|
+
fenceUntrusted(extra.trim())
|
|
1592
|
+
] : []
|
|
1593
|
+
].join("\n");
|
|
1594
|
+
}
|
|
1595
|
+
/** 容错解析:取首个 JSON 数组块,逐项校验字段。 */
|
|
1596
|
+
function parseDigestOutput(text) {
|
|
1597
|
+
const start = text.indexOf("[");
|
|
1598
|
+
const end = text.lastIndexOf("]");
|
|
1599
|
+
if (start === -1 || end <= start) return [];
|
|
1600
|
+
let parsed;
|
|
1601
|
+
try {
|
|
1602
|
+
parsed = JSON.parse(text.slice(start, end + 1));
|
|
1603
|
+
} catch {
|
|
1604
|
+
return [];
|
|
1605
|
+
}
|
|
1606
|
+
const out = [];
|
|
1607
|
+
for (const item of unknownArray$1(parsed)) if (isRecord(item)) {
|
|
1608
|
+
const category = fieldOf(item, "category");
|
|
1609
|
+
const statement = fieldOf(item, "statement");
|
|
1610
|
+
const signature = fieldOf(item, "signature");
|
|
1611
|
+
if (typeof category === "string" && typeof statement === "string") out.push({
|
|
1612
|
+
category,
|
|
1613
|
+
signature: typeof signature === "string" && signature.trim().length > 0 ? signature : statement.slice(0, 60),
|
|
1614
|
+
statement
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
return out;
|
|
1618
|
+
}
|
|
1619
|
+
//#endregion
|
|
1620
|
+
//#region lib/digest.ts
|
|
1621
|
+
/** 未知数组:非数组退空表(不经 any 扩散,逐元素仍是 unknown)。 */
|
|
1622
|
+
function unknownArray(value) {
|
|
1623
|
+
return Array.isArray(value) ? value : [];
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* /lessons-digest 回执里的单条候选规则行:category 与 statement 都是模型产出
|
|
1627
|
+
* (不可信),逐字段定界后才进给人看的编号行。
|
|
1628
|
+
*/
|
|
1629
|
+
function digestCreatedLine(rule, index, messages) {
|
|
1630
|
+
return fill(messages.digestCreatedLine, {
|
|
1631
|
+
index,
|
|
1632
|
+
category: fenceUntrusted(rule.category),
|
|
1633
|
+
statement: fenceUntrusted(rule.statement)
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
/** 蒸馏墙钟的**默认值**(真值由 host 的 entry config `digestTimeoutMs` 交进来,
|
|
1637
|
+
* 这里只剩 schema `.default()` 的单一来源)。 */
|
|
1638
|
+
const DEFAULT_DIGEST_TIMEOUT_MS = 12e4;
|
|
1639
|
+
/**
|
|
1640
|
+
* drain llm.stream → 全文。流块与终态就此是**官方判别联合**(installed
|
|
1641
|
+
* @deepseek-ai/dsh-llm/lib/types/index.d.ts 的 `StreamChunk` 与 `FinishReason`,
|
|
1642
|
+
* dir-prep-organize 同批绑定):`switch/===` 比 `chunk.type` 即收窄载荷,块换名或换字段
|
|
1643
|
+
* 在本文件当场编译不过——旧镜像把三块形状手抄成 `{ type?: string; text?: unknown;
|
|
1644
|
+
* reason?: {...} }`,那份抄本既不允许编译器认识真实的块集合,也 let 本包把"缺 text"
|
|
1645
|
+
* 这种事写成合法形状。
|
|
1646
|
+
* 交付侧的宽容保留:`typeof chunk.text === "string"` 与 `typeof failed?.message === "string"`
|
|
1647
|
+
* 都不删。`kind` 的兜底走 kindLabel():官方形状说的是适配器的**契约**,
|
|
1648
|
+
* 而 test/digest.test.ts 钉的是**实际吐出来的块**缺字段 / kind 非字符串那几档 ——
|
|
1649
|
+
* 声明里有没有,和运行时读不读得懂,是两件事,缺了后者就会把 `{kind}` 印给用户。
|
|
1650
|
+
*/
|
|
1651
|
+
/**
|
|
1652
|
+
* 从终态块里取"结束原因"的**可打印标签**。
|
|
1653
|
+
*
|
|
1654
|
+
* 参数刻意是 `unknown` 而不是 `FinishReason`:官方判别联合声明每档必带 `kind`,
|
|
1655
|
+
* 那说的是适配器**承诺**的形状;跨界载荷实际会漂移(test/digest.test.ts 的 weirdReason
|
|
1656
|
+
* 就钉"连 kind 都缺"这一档)。若照声明类型把这里的兜底当成死代码删掉,
|
|
1657
|
+
* `{kind}` 会原样留在用户看得见的文案里 —— 此前就是这么改红过一条断言。
|
|
1658
|
+
* 按 `unknown` 建模,守卫与类型面才是同一件事,类型感知规则也不会反过来把守卫判成冗余。
|
|
1659
|
+
*/
|
|
1660
|
+
function kindLabel(reason) {
|
|
1661
|
+
const kind = typeof reason === "object" && reason !== null ? Reflect.get(reason, "kind") : void 0;
|
|
1662
|
+
return typeof kind === "string" && kind !== "" ? kind : "unknown";
|
|
1663
|
+
}
|
|
1664
|
+
async function drainStreamToText(stream, messages) {
|
|
1665
|
+
let text = "";
|
|
1666
|
+
let finish;
|
|
1667
|
+
for await (const chunk of stream) if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
1668
|
+
else if (chunk.type === "finish") finish = chunk.reason;
|
|
1669
|
+
if (finish === void 0) throw new Error(messages.errMissingFinishChunk);
|
|
1670
|
+
if (finish.kind !== "stop") {
|
|
1671
|
+
const failed = finish.kind === "aborted" || finish.kind === "error" ? finish.failure : void 0;
|
|
1672
|
+
const detail = typeof failed?.message === "string" ? `: ${failed.message}` : "";
|
|
1673
|
+
throw new Error(fill(messages.errIncompleteFinish, {
|
|
1674
|
+
kind: kindLabel(finish),
|
|
1675
|
+
failure: detail
|
|
1676
|
+
}));
|
|
1677
|
+
}
|
|
1678
|
+
return text.trim();
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* 执行一次蒸馏:差评收集 → LLM 归纳 → 建候选规则(origin 'lessons-digest')。
|
|
1682
|
+
* 任何一步失败都抛错由调用方呈现(命令 handler / 端点)。
|
|
1683
|
+
*/
|
|
1684
|
+
async function runDigest(store, session, llm, selection, options) {
|
|
1685
|
+
const { messages, extra, events } = options;
|
|
1686
|
+
if (selection?.provider === void 0 || selection.model === void 0) throw new Error(messages.errNoModelSelection);
|
|
1687
|
+
const feedbacks = collectNegativeFeedback(unknownArray(events));
|
|
1688
|
+
const project = deriveProject(session.header?.cwd);
|
|
1689
|
+
const prompt = buildDigestPrompt(feedbacks, lessonsBackground(store, project, messages), messages, extra);
|
|
1690
|
+
const controller = new AbortController();
|
|
1691
|
+
const timer = setTimeout(() => {
|
|
1692
|
+
controller.abort();
|
|
1693
|
+
}, options.timeoutMs);
|
|
1694
|
+
try {
|
|
1695
|
+
const raw = await drainStreamToText(llm.stream({
|
|
1696
|
+
provider: selection.provider,
|
|
1697
|
+
model: selection.model,
|
|
1698
|
+
messages: [{
|
|
1699
|
+
id: brandString(`lesson-loop-digest-${randomUUID()}`),
|
|
1700
|
+
role: "user",
|
|
1701
|
+
content: [{
|
|
1702
|
+
type: "text",
|
|
1703
|
+
text: prompt
|
|
1704
|
+
}],
|
|
1705
|
+
source: {
|
|
1706
|
+
kind: LESSON_SOURCE_KIND,
|
|
1707
|
+
form: "instructions"
|
|
1708
|
+
}
|
|
1709
|
+
}],
|
|
1710
|
+
system: digestSystemPrompt(messages),
|
|
1711
|
+
signal: controller.signal
|
|
1712
|
+
}), messages);
|
|
1713
|
+
if (raw.length === 0) throw new Error(messages.errEmptyDigest);
|
|
1714
|
+
const parsed = parseDigestOutput(raw);
|
|
1715
|
+
const addInOrder = async (index) => {
|
|
1716
|
+
const entry = parsed[index];
|
|
1717
|
+
if (entry === void 0) return [];
|
|
1718
|
+
const card = await store.addCandidate({
|
|
1719
|
+
project,
|
|
1720
|
+
category: entry.category,
|
|
1721
|
+
signature: entry.signature,
|
|
1722
|
+
statement: entry.statement,
|
|
1723
|
+
detail: prompt,
|
|
1724
|
+
source: "lessons-digest"
|
|
1725
|
+
});
|
|
1726
|
+
const rest = await addInOrder(index + 1);
|
|
1727
|
+
return [{
|
|
1728
|
+
category: card.category,
|
|
1729
|
+
signature: card.signature,
|
|
1730
|
+
statement: card.statement
|
|
1731
|
+
}, ...rest];
|
|
1732
|
+
};
|
|
1733
|
+
const created = await addInOrder(0);
|
|
1734
|
+
return {
|
|
1735
|
+
project,
|
|
1736
|
+
feedbackCount: feedbacks.length,
|
|
1737
|
+
created,
|
|
1738
|
+
raw
|
|
1739
|
+
};
|
|
1740
|
+
} finally {
|
|
1741
|
+
clearTimeout(timer);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
//#endregion
|
|
1745
|
+
//#region host.ts
|
|
1746
|
+
/** 无可回收资源的空清理(consistent-return:effect 各分支统一返回值)。 */
|
|
1747
|
+
const noDisposer = () => {};
|
|
1748
|
+
const ALLOWED_ACTIONS = /* @__PURE__ */ new Set([
|
|
1749
|
+
"arm",
|
|
1750
|
+
"reject",
|
|
1751
|
+
"demote",
|
|
1752
|
+
"archive",
|
|
1753
|
+
"edit",
|
|
1754
|
+
"revive"
|
|
1755
|
+
]);
|
|
1756
|
+
function isRuleAction(value) {
|
|
1757
|
+
return typeof value === "string" && ALLOWED_ACTIONS.has(value);
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* `webServer` 与 `commands` 两枚可选服务**共用**的运行时判据:值是 record,且 `register`
|
|
1761
|
+
* 那一位可调用。两枚投影({@link WebServerService} / {@link CommandsService})的
|
|
1762
|
+
* `register` **签名**来自两份不同的官方类(`WebRoute` vs `CommandDefinition`),`Pick<>`
|
|
1763
|
+
* 出来是两台名义类型,收窄只能各留一枚;但结构检查只有一份,故收敛到这个函数,
|
|
1764
|
+
* 由两枚守卫各自一行委托(此前是两份逐字相同的六行抄本)。
|
|
1765
|
+
*/
|
|
1766
|
+
function hasCallableRegister(value) {
|
|
1767
|
+
if (!isRecord(value)) return false;
|
|
1768
|
+
const { register } = value;
|
|
1769
|
+
return typeof register === "function";
|
|
1770
|
+
}
|
|
1771
|
+
function isWebServer(value) {
|
|
1772
|
+
return hasCallableRegister(value);
|
|
1773
|
+
}
|
|
1774
|
+
function isCommandsService(value) {
|
|
1775
|
+
return hasCallableRegister(value);
|
|
1776
|
+
}
|
|
1777
|
+
function isSystemPromptService(value) {
|
|
1778
|
+
if (!isRecord(value)) return false;
|
|
1779
|
+
const { section } = value;
|
|
1780
|
+
return typeof section === "function";
|
|
1781
|
+
}
|
|
1782
|
+
function isTimerService(value) {
|
|
1783
|
+
if (!isRecord(value)) return false;
|
|
1784
|
+
const { timeout } = value;
|
|
1785
|
+
return typeof timeout === "function";
|
|
1786
|
+
}
|
|
1787
|
+
function isSessionQueryService(value) {
|
|
1788
|
+
if (!isRecord(value)) return false;
|
|
1789
|
+
const { observeSession } = value;
|
|
1790
|
+
return typeof observeSession === "function";
|
|
1791
|
+
}
|
|
1792
|
+
function isLlmService(value) {
|
|
1793
|
+
if (!isRecord(value)) return false;
|
|
1794
|
+
const { stream } = value;
|
|
1795
|
+
return typeof stream === "function";
|
|
1796
|
+
}
|
|
1797
|
+
function isAgentDefaultModel(value) {
|
|
1798
|
+
if (!isRecord(value)) return false;
|
|
1799
|
+
const { currentSelection } = value;
|
|
1800
|
+
return typeof currentSelection === "function";
|
|
1801
|
+
}
|
|
1802
|
+
/** 会话最小投影(/lessons-digest 命令入参的 agent.session)。 */
|
|
1803
|
+
function isSessionLike(value) {
|
|
1804
|
+
return isRecord(value);
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* 宿主 ctx 结构契约守卫(apply 把 Context 收窄为 HostCtx,不写断言)。
|
|
1808
|
+
* 核验的是 0.1.7 的实际调用面:`effect`/`on` 接线、`inject` + `fiber` 挂页面策略、
|
|
1809
|
+
* `settings.describe` 做跨命名空间读与规则库读。缺任一项一律急停——带着半截服务面
|
|
1810
|
+
* 在首个回合才崩,会被折叠成 turn kind:error 直接甩给用户。
|
|
1811
|
+
*/
|
|
1812
|
+
function isHostCtx(value) {
|
|
1813
|
+
if (!isRecord(value)) return false;
|
|
1814
|
+
const { effect, fiber, inject, on, settings } = value;
|
|
1815
|
+
return typeof effect === "function" && typeof on === "function" && typeof inject === "function" && fiber !== void 0 && typeof fieldOf(settings, "describe") === "function";
|
|
1816
|
+
}
|
|
1817
|
+
/** stats/rules/lessons GET 端点(下发 csrf)。 */
|
|
1818
|
+
const STATS_PATH = "/_dsh/lesson-loop/stats";
|
|
1819
|
+
const RULES_PATH = "/_dsh/lesson-loop/rules";
|
|
1820
|
+
const LESSONS_PATH = "/_dsh/lesson-loop/lessons";
|
|
1821
|
+
/** 写端点(POST,csrf + 同源校验)。 */
|
|
1822
|
+
const RULE_ACTION_PATH = "/_dsh/lesson-loop/rule-action";
|
|
1823
|
+
/**
|
|
1824
|
+
* provider 是否具备规则库需要的 CAS 面(describe + update);缺位即规则面不可用。
|
|
1825
|
+
* ⚠ 不能再探 `register`:0.1.7 已把它连同 get/installSection 一起移除,拿它当硬前置会让
|
|
1826
|
+
* 每一条真宿主都判成"没有 CAS 面",规则库于是结构性失效。
|
|
1827
|
+
* 导出仅供单测:非记录对象这条分支在 apply 里到不了(更早就用了 svc.settings.describe),
|
|
1828
|
+
* 但守卫本身要对它负责,故直验而不是伪造一条走不到的装载路径。
|
|
1829
|
+
*/
|
|
1830
|
+
function isSettingsProvider(value) {
|
|
1831
|
+
if (!isRecord(value)) return false;
|
|
1832
|
+
const { describe, update } = value;
|
|
1833
|
+
return typeof describe === "function" && typeof update === "function";
|
|
1834
|
+
}
|
|
1835
|
+
const configSchema = Schema.object({
|
|
1836
|
+
enabled: Schema.boolean().default(true).volatile(),
|
|
1837
|
+
reportEnabled: Schema.boolean().default(true).volatile(),
|
|
1838
|
+
injectEnabled: Schema.boolean().default(true).volatile(),
|
|
1839
|
+
sectionEnabled: Schema.boolean().default(true).volatile(),
|
|
1840
|
+
promoteThreshold: Schema.natural().min(1).max(20).default(3).volatile(),
|
|
1841
|
+
demoteThreshold: Schema.natural().min(1).max(50).default(3).volatile(),
|
|
1842
|
+
demoteMinSamples: Schema.natural().min(1).max(100).default(5).volatile(),
|
|
1843
|
+
demoteRatio: Schema.number().min(.05).max(1).step(.05).default(.5).volatile(),
|
|
1844
|
+
decayDays: Schema.natural().min(1).max(365).default(30).volatile(),
|
|
1845
|
+
reviveThreshold: Schema.natural().min(1).max(20).default(3).volatile(),
|
|
1846
|
+
maxLessonsBytes: Schema.natural().max(1073741824).default(0).volatile(),
|
|
1847
|
+
rules: RulesFieldSchema,
|
|
1848
|
+
digestTimeoutMs: Schema.natural().min(1e3).default(DEFAULT_DIGEST_TIMEOUT_MS),
|
|
1849
|
+
decayIntervalMs: Schema.natural().min(1e3).default(864e5)
|
|
1850
|
+
});
|
|
1851
|
+
/**
|
|
1852
|
+
* 派生数据的落盘位置:官方 cache 目录(`dshCachePath` = `$DSH_HOME/cache`,
|
|
1853
|
+
* DSH_HOME 未设时由 home-paths 自己按 `~/.dsh` 解析)。
|
|
1854
|
+
*
|
|
1855
|
+
* 为什么是 cache:dsh 承认的用户数据目录只有 settings 文档与 sessions/storages/
|
|
1856
|
+
* cache/logs,插件自开 `metrics/` 属越界(用户的手工编辑、备份、清理都不在官方面上)。
|
|
1857
|
+
* 事件流水正是 cache 定位的"可丢弃派生数据"——丢了能由会话与规则库重建,不该占用
|
|
1858
|
+
* 用户唯一那份 settings.yaml。自制 `DSH_HOME → HOME/.dsh → throw` 三态回退一并删掉:
|
|
1859
|
+
* 解析规则由官方包单源提供,插件不再第二份实现(也不再有"两个环境变量都没有就炸"
|
|
1860
|
+
* 这种热路径抛错)。
|
|
1861
|
+
*/
|
|
1862
|
+
function cacheFile(name) {
|
|
1863
|
+
return dshCachePath(PLUGIN_NAME, name);
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* 建规则库端口(交给 LessonStore)。0.1.7 起这里**不注册任何东西**:命名空间与 `rules`
|
|
1867
|
+
* 字段都是宿主从条目导出的 Config 反推的(`settings.register` 已移除)。
|
|
1868
|
+
*
|
|
1869
|
+
* 两条降级路径:
|
|
1870
|
+
* - provider 没有 CAS 面(describe/update 缺位)→ 这里当场点破并让规则面读空写拒,
|
|
1871
|
+
* 设置卡开关照改照生效;这条判据与装载次序无关,所以留在装载期。
|
|
1872
|
+
* - 那一段读不出可信值(用户把 `rules` 手改成非数组)→ 由端口自己在**第一次真读到那一行
|
|
1873
|
+
* 时**报(`lib/rules-namespace.ts` 的 load)。装载期报不了:apply 跑在自己那条 fiber
|
|
1874
|
+
* 变 ACTIVE 之前,那一刻 describe() 里没有本条目的行(真实宿主隔离实测),在这一点上
|
|
1875
|
+
* 判"坏"是错的、判"还没到"则每次正常开机都多一条噪声。
|
|
1876
|
+
* 旧实现靠 try/catch 接住"register 被打回",现在没有可 catch 的注册:同段之后,坏
|
|
1877
|
+
* `rules` 若走到类型判定就会让整条条目不加载(cordis resolveConfig 抛 → 插件根本不
|
|
1878
|
+
* 执行,开关与服务一起没)。所以那一步由 schema 上的 `.loose()` 在解析期吸收,坏形状
|
|
1879
|
+
* 只在读写侧点名——不点名就会被当成"库里真的没有卡"。
|
|
1880
|
+
*/
|
|
1881
|
+
function createRulesFacet(settings) {
|
|
1882
|
+
if (!isSettingsProvider(settings)) {
|
|
1883
|
+
console.error("[lesson-loop] settings provider has no CAS surface — rule library unavailable (switches still work)");
|
|
1884
|
+
return createRulesRepository(null);
|
|
1885
|
+
}
|
|
1886
|
+
return createRulesRepository(settings);
|
|
1887
|
+
}
|
|
1888
|
+
/** CSRF header 名(各插件历史上不同名,收敛时保持各自语义)。 */
|
|
1889
|
+
const LESSON_CSRF_HEADER = "x-lesson-csrf";
|
|
1890
|
+
/** rule-action 请求体上限(UTF-8 字节):合法载荷只有 id/action/statement 三个短串。 */
|
|
1891
|
+
const RULE_ACTION_BODY_MAX_BYTES = 1048576;
|
|
1892
|
+
/** 会话台账容量上限(ctx-observe 同款纪律:防长驻进程无界)。 */
|
|
1893
|
+
const WATCH_MAX_SESSIONS = 200;
|
|
1894
|
+
/** 规则卡的 HTTP 投影:去掉 evidence[],并补上派生态 undeterminable。
|
|
1895
|
+
* 证据是全文(一条教训的 detail 可含整段工具输出),而卡片只渲染状态与计数——
|
|
1896
|
+
* 原样序列化等于每次轮询把整份证据库传给浏览器再丢掉。不可判定是"该不该由人处理"
|
|
1897
|
+
* 的判据(既不自动降级也不自动归档),必须在投影里带出去让人看到。
|
|
1898
|
+
*/
|
|
1899
|
+
function ruleViewOf(store, rule) {
|
|
1900
|
+
const { evidence: _evidence, ...rest } = rule;
|
|
1901
|
+
return {
|
|
1902
|
+
...rest,
|
|
1903
|
+
undeterminable: store.isUndeterminable(rule)
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
/** GET stats:全量规则投影 + csrf(卡片轮询的入口,也是 csrf 的唯一来源)。 */
|
|
1907
|
+
function handleStatsRoute(req, res, { csrf, servingNonLoopback, stats, store }) {
|
|
1908
|
+
if (!guardTrust(req, res, { servingNonLoopback })) return;
|
|
1909
|
+
if (req.method !== "GET") {
|
|
1910
|
+
res.setHeader("Allow", "GET");
|
|
1911
|
+
sendJson(res, 405, {
|
|
1912
|
+
ok: false,
|
|
1913
|
+
error: "GET only"
|
|
1914
|
+
});
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
sendJson(res, 200, {
|
|
1918
|
+
ok: true,
|
|
1919
|
+
csrf,
|
|
1920
|
+
...stats(),
|
|
1921
|
+
rules: store.rules().map((rule) => ruleViewOf(store, rule))
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
/** GET rules:project 缺省给全量,给定即按卡片自存的 project 过滤。 */
|
|
1925
|
+
function handleRulesRoute(req, res, { csrf, servingNonLoopback, store }) {
|
|
1926
|
+
if (!guardTrust(req, res, { servingNonLoopback })) return;
|
|
1927
|
+
if (req.method !== "GET") {
|
|
1928
|
+
res.setHeader("Allow", "GET");
|
|
1929
|
+
sendJson(res, 405, {
|
|
1930
|
+
ok: false,
|
|
1931
|
+
error: "GET only"
|
|
1932
|
+
});
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const project = queryParam(req, "project");
|
|
1936
|
+
const all = store.rules();
|
|
1937
|
+
const rules = (project === null ? all : all.filter((rule) => rule.project === project)).map((rule) => ruleViewOf(store, rule));
|
|
1938
|
+
sendJson(res, 200, {
|
|
1939
|
+
ok: true,
|
|
1940
|
+
csrf,
|
|
1941
|
+
rules
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
/** GET lessons:事件流水只读投影,零写面。 */
|
|
1945
|
+
function handleLessonsRoute(req, res, { servingNonLoopback, store }) {
|
|
1946
|
+
if (!guardTrust(req, res, { servingNonLoopback })) return;
|
|
1947
|
+
if (req.method !== "GET") {
|
|
1948
|
+
res.setHeader("Allow", "GET");
|
|
1949
|
+
sendJson(res, 405, {
|
|
1950
|
+
ok: false,
|
|
1951
|
+
error: "GET only"
|
|
1952
|
+
});
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const project = queryParam(req, "project");
|
|
1956
|
+
const limitRaw = Number(queryParam(req, "limit") ?? "0");
|
|
1957
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 0;
|
|
1958
|
+
const lessons = store.recentLessons(project ?? void 0, limit);
|
|
1959
|
+
sendJson(res, 200, {
|
|
1960
|
+
ok: true,
|
|
1961
|
+
count: lessons.length,
|
|
1962
|
+
lessons
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
/** POST rule-action 的体:csrf 令牌校验 → 入参白名单 → 落库并按结果定状态码。 */
|
|
1966
|
+
async function applyRuleAction(req, res, { csrf, messagesOf, store }) {
|
|
1967
|
+
if (req.method !== "POST") {
|
|
1968
|
+
res.setHeader("Allow", "POST");
|
|
1969
|
+
sendJson(res, 405, {
|
|
1970
|
+
ok: false,
|
|
1971
|
+
error: "POST only"
|
|
1972
|
+
});
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
const raw = await guardBody(req, res, {
|
|
1976
|
+
maxBytes: RULE_ACTION_BODY_MAX_BYTES,
|
|
1977
|
+
csrf: {
|
|
1978
|
+
token: csrf,
|
|
1979
|
+
headerName: LESSON_CSRF_HEADER
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
if (raw === null) return;
|
|
1983
|
+
const messages = messagesOf();
|
|
1984
|
+
let parsed;
|
|
1985
|
+
try {
|
|
1986
|
+
parsed = JSON.parse(raw);
|
|
1987
|
+
} catch {
|
|
1988
|
+
sendJson(res, 400, {
|
|
1989
|
+
ok: false,
|
|
1990
|
+
error: messages.errInvalidJsonBody
|
|
1991
|
+
});
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
const idVal = fieldOf(parsed, "id");
|
|
1995
|
+
const id = typeof idVal === "string" ? idVal : "";
|
|
1996
|
+
const actionVal = fieldOf(parsed, "action");
|
|
1997
|
+
const action = typeof actionVal === "string" ? actionVal : "";
|
|
1998
|
+
const statementVal = fieldOf(parsed, "statement");
|
|
1999
|
+
const statement = typeof statementVal === "string" ? statementVal : void 0;
|
|
2000
|
+
if (id === "" || action === "") {
|
|
2001
|
+
sendJson(res, 400, {
|
|
2002
|
+
ok: false,
|
|
2003
|
+
error: messages.errIdAndActionRequired
|
|
2004
|
+
});
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (!isRuleAction(action)) {
|
|
2008
|
+
sendJson(res, 400, {
|
|
2009
|
+
ok: false,
|
|
2010
|
+
error: fill(messages.errUnknownAction, { action })
|
|
2011
|
+
});
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
const outcome = await store.ruleAction(id, action, statement);
|
|
2015
|
+
if (outcome === "not-found") {
|
|
2016
|
+
sendJson(res, 404, {
|
|
2017
|
+
ok: false,
|
|
2018
|
+
error: messages.errRuleNotFound
|
|
2019
|
+
});
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (outcome === "persist-failed") {
|
|
2023
|
+
sendJson(res, 500, {
|
|
2024
|
+
ok: false,
|
|
2025
|
+
error: PERSIST_FAILED
|
|
2026
|
+
});
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
sendJson(res, 200, {
|
|
2030
|
+
ok: true,
|
|
2031
|
+
id,
|
|
2032
|
+
action
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
/** POST rule-action 的壳:闸门 + 兜底 catch(体本身失败一律 500,不许漏成 unhandled rejection)。 */
|
|
2036
|
+
async function handleRuleActionRoute(req, res, routes) {
|
|
2037
|
+
if (!guardTrust(req, res, { servingNonLoopback: routes.servingNonLoopback })) return;
|
|
2038
|
+
try {
|
|
2039
|
+
await applyRuleAction(req, res, routes);
|
|
2040
|
+
} catch (error) {
|
|
2041
|
+
console.error(`[lesson-loop] rule-action handler failed: ${errorText(error)}`);
|
|
2042
|
+
sendJson(res, 500, {
|
|
2043
|
+
ok: false,
|
|
2044
|
+
error: routes.messagesOf().errRuleActionFailed
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
/** 端点装载体:读服务 → 取权威信号与 csrf → 四条路由逐个注册 → 交回合并 disposer。
|
|
2049
|
+
* 服务缺席(TUI 宿主没有 webServer)时只交回空清理,装载不许中断。 */
|
|
2050
|
+
function installLessonRoutes(svc, store, stats, messagesOf) {
|
|
2051
|
+
const webServerRaw = svc.get?.("webServer");
|
|
2052
|
+
if (!isWebServer(webServerRaw)) return noDisposer;
|
|
2053
|
+
const webServer = webServerRaw;
|
|
2054
|
+
const servingNonLoopback = webServer.host === "0.0.0.0";
|
|
2055
|
+
const routes = {
|
|
2056
|
+
store,
|
|
2057
|
+
stats,
|
|
2058
|
+
messagesOf,
|
|
2059
|
+
csrf: randomUUID(),
|
|
2060
|
+
servingNonLoopback
|
|
2061
|
+
};
|
|
2062
|
+
const disposeStats = webServer.register({
|
|
2063
|
+
kind: "exact",
|
|
2064
|
+
path: STATS_PATH,
|
|
2065
|
+
handler: (req, res) => {
|
|
2066
|
+
handleStatsRoute(req, res, routes);
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
const disposeRules = webServer.register({
|
|
2070
|
+
kind: "exact",
|
|
2071
|
+
path: RULES_PATH,
|
|
2072
|
+
handler: (req, res) => {
|
|
2073
|
+
handleRulesRoute(req, res, routes);
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
const disposeLessons = webServer.register({
|
|
2077
|
+
kind: "exact",
|
|
2078
|
+
path: LESSONS_PATH,
|
|
2079
|
+
handler: (req, res) => {
|
|
2080
|
+
handleLessonsRoute(req, res, routes);
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
const disposeAction = webServer.register({
|
|
2084
|
+
kind: "exact",
|
|
2085
|
+
path: RULE_ACTION_PATH,
|
|
2086
|
+
handler: (req, res) => handleRuleActionRoute(req, res, routes)
|
|
2087
|
+
});
|
|
2088
|
+
return () => {
|
|
2089
|
+
disposeStats();
|
|
2090
|
+
disposeRules();
|
|
2091
|
+
disposeLessons();
|
|
2092
|
+
disposeAction();
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
/** 注册 webServer 端点:GET stats/rules/lessons(下发 csrf)+ POST rule-action(csrf 校验)。
|
|
2096
|
+
* 端点回显给卡片的 error 文案随语言偏好走(卡片把它们直接显示进 .llc-err)。
|
|
2097
|
+
* ⚠ `owner` 必须是 apply 末尾那个 `svc.inject(["webServer"], …)` 交回的子上下文:效应挂在
|
|
2098
|
+
* 子 fiber 上才跟着依赖"后到即注册、换实例先卸后装"。挂在父 ctx 上会变成"父 fiber 卸载时
|
|
2099
|
+
* 才清",依赖每次变化都多留一组路由。
|
|
2100
|
+
* 类型取本包对子上下文的投影(`InjectedCtx` 的 effect 位),不取官方 `Context["effect"]`:
|
|
2101
|
+
* 本包那份是比官方宽一格的本地镜像(官方两条重载的返回域是 Disposable/AsyncDisposable),
|
|
2102
|
+
* 混用会当场对不上——收窄到"这里只用到 effect"这一位即可。 */
|
|
2103
|
+
function registerRoutes(svc, owner, store, stats, messagesOf) {
|
|
2104
|
+
owner.effect(() => installLessonRoutes(svc, store, stats, messagesOf), "lesson-loop: webServer routes");
|
|
2105
|
+
}
|
|
2106
|
+
/** commands 服务缺失只 warn 一次(模块级:一次进程内多次装载不刷屏)。
|
|
2107
|
+
* 位装在 const 盒子里而不是裸 `let`:模块级可变声明正是"谁都能改"的那种面,
|
|
2108
|
+
* 盒子只暴露这一位,改的对象仍是同一份进程内状态。 */
|
|
2109
|
+
const commandsWarnState = { warned: false };
|
|
2110
|
+
/** 数组性判据(**不是** type predicate,故不产生收窄;理由见 digestEvents 内注释)。 */
|
|
2111
|
+
function isArrayDelivery(value) {
|
|
2112
|
+
return Array.isArray(value);
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* 取一次蒸馏要用的会话事件流:官方 `ctx.sessionQuery.observeSession(sessionId)` 的
|
|
2116
|
+
* `observation.events`。
|
|
2117
|
+
* 三条降级路径一律回**空表**(宁可少背景,不可炸命令,语义与迁移前的 `readSessionEvents`
|
|
2118
|
+
* 逐条对齐):
|
|
2119
|
+
* 1. `sessionQuery` 未装配(非该 profile / 精简 bundle)——`get` 交回 undefined,
|
|
2120
|
+
* 官方语义即"or `undefined` when not (yet) provided"(installed
|
|
2121
|
+
* @deepseek-ai/cordis/lib/types/reflect.d.ts:12)。
|
|
2122
|
+
* 2. 拿不到 `SessionId`:本包的 {@link SessionLike} 只借官方键名、值域按边界收窄
|
|
2123
|
+
* (`id?: Session["id"]`),递来的对象完全可能没有 id 或不是字符串。旧写法不需要这一步
|
|
2124
|
+
* (它直接读对象上的方法),换成按 sessionId 查询后,这道理所应当是一道守卫。
|
|
2125
|
+
* 3. `observeSession` 抛错/拒绝(会话已卸载、存储读不出、取消信号命中)。
|
|
2126
|
+
*
|
|
2127
|
+
* `brandString<SessionId>` 是官方品牌的唯一合法构造口(幻影品牌无 type-only 构造方式,
|
|
2128
|
+
* `as` 又被 typescript/no-unsafe-type-assertion 禁掉),见文件头列出的 dsh-brand 值导入。
|
|
2129
|
+
*/
|
|
2130
|
+
async function digestEvents(svc, session) {
|
|
2131
|
+
const queryRaw = svc.get?.("sessionQuery");
|
|
2132
|
+
if (!isSessionQueryService(queryRaw)) return [];
|
|
2133
|
+
const sessionId = typeof session.id === "string" && session.id.length > 0 ? session.id : void 0;
|
|
2134
|
+
if (sessionId === void 0) return [];
|
|
2135
|
+
try {
|
|
2136
|
+
const observation = await queryRaw.observeSession(brandString(sessionId));
|
|
2137
|
+
if (!isArrayDelivery(observation.events)) return [];
|
|
2138
|
+
return observation.events;
|
|
2139
|
+
} catch {
|
|
2140
|
+
return [];
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
/** 注册 /lessons-digest 命令(人工触发蒸馏;commands 服务可选读——缺失只 warn 一次)。
|
|
2144
|
+
* 与 report 同一闸门:插件关闭期间既不烧 LLM 也不写候选卡。命令回显与进模型的蒸馏
|
|
2145
|
+
* 提示都随语言偏好走;蒸馏出来的规则条目是用户数据,原样透传。 */
|
|
2146
|
+
function registerDigestCommand(svc, store, settingsOf, messagesOf) {
|
|
2147
|
+
svc.effect(() => {
|
|
2148
|
+
const commandsRaw = svc.get?.("commands");
|
|
2149
|
+
if (!isCommandsService(commandsRaw)) {
|
|
2150
|
+
if (!commandsWarnState.warned) {
|
|
2151
|
+
commandsWarnState.warned = true;
|
|
2152
|
+
console.warn("[lesson-loop] commands service unavailable — /lessons-digest not registered");
|
|
2153
|
+
}
|
|
2154
|
+
return noDisposer;
|
|
2155
|
+
}
|
|
2156
|
+
const commands = commandsRaw;
|
|
2157
|
+
const description = messagesOf().digestCommandDescription;
|
|
2158
|
+
return commands.register({
|
|
2159
|
+
name: "lessons-digest",
|
|
2160
|
+
description,
|
|
2161
|
+
input: { hint: messagesOf().digestInputHint },
|
|
2162
|
+
recordInput: false,
|
|
2163
|
+
handler: async (invocationRaw) => {
|
|
2164
|
+
const messages = messagesOf();
|
|
2165
|
+
const cfg = settingsOf();
|
|
2166
|
+
if (!cfg.enabled || !cfg.reportEnabled) return {
|
|
2167
|
+
kind: "error",
|
|
2168
|
+
text: messages.digestRejectedDisabled
|
|
2169
|
+
};
|
|
2170
|
+
const agent = fieldOf(invocationRaw, "agent");
|
|
2171
|
+
const sessionRaw = isRecord(agent) ? fieldOf(agent, "session") : void 0;
|
|
2172
|
+
if (!isSessionLike(sessionRaw)) return {
|
|
2173
|
+
kind: "error",
|
|
2174
|
+
text: messages.digestRejectedNoSession
|
|
2175
|
+
};
|
|
2176
|
+
const session = sessionRaw;
|
|
2177
|
+
const llmRaw = svc.get?.("llm");
|
|
2178
|
+
if (!isLlmService(llmRaw)) return {
|
|
2179
|
+
kind: "error",
|
|
2180
|
+
text: messages.digestRejectedNoLlm
|
|
2181
|
+
};
|
|
2182
|
+
const llm = llmRaw;
|
|
2183
|
+
const modelSvcRaw = svc.get?.("agentDefaultModel");
|
|
2184
|
+
const selection = isAgentDefaultModel(modelSvcRaw) ? modelSvcRaw.currentSelection() : void 0;
|
|
2185
|
+
const rawInput = fieldOf(invocationRaw, "rawInput");
|
|
2186
|
+
const extra = typeof rawInput === "string" && rawInput.trim().length > 0 ? rawInput : void 0;
|
|
2187
|
+
const events = await digestEvents(svc, session);
|
|
2188
|
+
try {
|
|
2189
|
+
const result = await runDigest(store, session, llm, selection, {
|
|
2190
|
+
messages,
|
|
2191
|
+
events,
|
|
2192
|
+
timeoutMs: settingsOf().digestTimeoutMs,
|
|
2193
|
+
...extra === void 0 ? {} : { extra }
|
|
2194
|
+
});
|
|
2195
|
+
if (result.created.length === 0) return {
|
|
2196
|
+
kind: "success",
|
|
2197
|
+
text: fill(messages.digestNoNewRules, { count: result.feedbackCount })
|
|
2198
|
+
};
|
|
2199
|
+
const lines = result.created.map((rule, index) => digestCreatedLine(rule, index + 1, messages));
|
|
2200
|
+
return {
|
|
2201
|
+
kind: "success",
|
|
2202
|
+
text: fill(messages.digestCreated, {
|
|
2203
|
+
count: result.created.length,
|
|
2204
|
+
lines: lines.join("\n")
|
|
2205
|
+
})
|
|
2206
|
+
};
|
|
2207
|
+
} catch (error) {
|
|
2208
|
+
return {
|
|
2209
|
+
kind: "error",
|
|
2210
|
+
text: fill(messages.digestFailed, { reason: errorText(error) })
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
}, "lesson-loop: /lessons-digest command");
|
|
2216
|
+
}
|
|
2217
|
+
/** 启动时的存量碎片迁移:路径型签名的候选/armed 碎片并入类别级
|
|
2218
|
+
* 稳定签名卡。幂等——迁移后不再有匹配碎片,重复调用是 no-op。容错。 */
|
|
2219
|
+
async function runFragmentMigration(store) {
|
|
2220
|
+
try {
|
|
2221
|
+
const merged = await store.migrateFragmentRules();
|
|
2222
|
+
if (merged > 0) console.info(`[lesson-loop] migrated ${merged} fragment rule(s) to stable signatures`);
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
console.error(`[lesson-loop] fragment migration failed: ${errorText(error)}`);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
/** 启动时的 project 键归一迁移:桶键派生收进 shared/lib/project-key.ts(补
|
|
2228
|
+
* path.resolve + realpath)后,按卡片自存的 cwd 重算 project 并归并撞车卡。
|
|
2229
|
+
* 幂等;与碎片迁移同一纪律——失败只记日志,装载不许被存量数据炸断。 */
|
|
2230
|
+
async function runProjectKeyMigration(store) {
|
|
2231
|
+
try {
|
|
2232
|
+
const { rewrites, merged } = await store.migrateProjectKeys();
|
|
2233
|
+
if (rewrites > 0) console.info(`[lesson-loop] normalized ${rewrites} rule project key(s)${merged > 0 ? `, merged ${merged} collision(s)` : ""}`);
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
console.error(`[lesson-loop] project key migration failed: ${errorText(error)}`);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* `settleQuietly` 在三个收尾位(session/disposed 两支 + 卸载清算)共用同一句失败日志前缀:
|
|
2240
|
+
* 三处清算的都是 `store.sessionEnded`,措辞漂移会让同一条错误在日志里变成三种样子。
|
|
2241
|
+
*/
|
|
2242
|
+
const SESSION_ENDED_FAILURE_LABEL = "sessionEnded failed";
|
|
2243
|
+
/**
|
|
2244
|
+
* 收尾清算的异步兜底:sessionEnded 要 await provider 落盘。
|
|
2245
|
+
* **事件回调**位(agent/disposed、session/disposed)仍是同步契约 → 起链、失败只记日志
|
|
2246
|
+
* (单条会话炸了不许拖垮其余、更不许把事件派发带炸;未处理的 rejection 在 Node 默认
|
|
2247
|
+
* 配置下会直接退出进程)。**卸载**位不用这个函数:那里交回可 await 的 disposer,见
|
|
2248
|
+
* settleOpenSessionsOnDispose。
|
|
2249
|
+
*/
|
|
2250
|
+
async function settleQuietly(promise, label) {
|
|
2251
|
+
try {
|
|
2252
|
+
await promise;
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
console.error(`[lesson-loop] ${label}: ${errorText(error)}`);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
/**
|
|
2258
|
+
* 卸载时清算仍未收到 session/disposed 的会话。
|
|
2259
|
+
*
|
|
2260
|
+
* 为什么需要:headless/CLI 进程只跑一个任务就退出,Cordis 拆树时插件 fiber 先于会话
|
|
2261
|
+
* 事件销毁——只挂在 session/disposed 上的收尾清算在这些进程里**永远不执行**,
|
|
2262
|
+
* samples/observed 因此恒为 0,armed 规则结构性落入"不可判定",度量层等于没做。
|
|
2263
|
+
* 闸门与容错口径与事件版一致(关闭期间不清算、单条失败不拖垮其余),但**交回可 await 的
|
|
2264
|
+
* 异步 disposer**:官方明说异步 disposer 由拆纤 await(cordis fiber.d.ts:38-41),
|
|
2265
|
+
* 旧的 `void settleQuietly(...)` 是 fire-and-forget —— 在飞的 provider 写会被截断,
|
|
2266
|
+
* 违反 defensive-patterns.md:19-21「Dispose must reach quiescence」。
|
|
2267
|
+
*/
|
|
2268
|
+
function settleOpenSessionsOnDispose(args) {
|
|
2269
|
+
const { store, sessionWatch, settingsOf } = args;
|
|
2270
|
+
return async () => {
|
|
2271
|
+
if (!settingsOf().enabled) return;
|
|
2272
|
+
const pending = [];
|
|
2273
|
+
for (const [sid, entry] of sessionWatch) {
|
|
2274
|
+
sessionWatch.delete(sid);
|
|
2275
|
+
pending.push(settleQuietly(store.sessionEnded(entry.project, entry.violated, entry.passed, sid), SESSION_ENDED_FAILURE_LABEL));
|
|
2276
|
+
}
|
|
2277
|
+
await Promise.all(pending);
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* 会话开始:①预种会话台账(project 归因,供收尾清算 suppressed);②注入 armed
|
|
2282
|
+
* 规则摘要(根会话;项目按 cwd 归因;全文不截断)。摘要帧的骨架行由消息表给,
|
|
2283
|
+
* 规则正文(statement)原样透传——那是规则库里的用户数据,不随界面语言改写。
|
|
2284
|
+
*/
|
|
2285
|
+
function onSessionStart(args) {
|
|
2286
|
+
const { agent, settingsOf, store, sessionWatch, pruneWatch, messages } = args;
|
|
2287
|
+
try {
|
|
2288
|
+
const cfg = settingsOf();
|
|
2289
|
+
if (!cfg.enabled) return;
|
|
2290
|
+
const { session } = agent;
|
|
2291
|
+
const sid = typeof session?.id === "string" ? session.id : void 0;
|
|
2292
|
+
const project = deriveProject(session?.header?.cwd);
|
|
2293
|
+
if (sid !== void 0) {
|
|
2294
|
+
const seeded = sessionWatch.get(sid);
|
|
2295
|
+
if (seeded === void 0) sessionWatch.set(sid, {
|
|
2296
|
+
project,
|
|
2297
|
+
violated: /* @__PURE__ */ new Set(),
|
|
2298
|
+
passed: /* @__PURE__ */ new Set()
|
|
2299
|
+
});
|
|
2300
|
+
else seeded.project = project;
|
|
2301
|
+
pruneWatch();
|
|
2302
|
+
}
|
|
2303
|
+
if (!cfg.injectEnabled) return;
|
|
2304
|
+
if (session === void 0 || typeof agent.inject !== "function") return;
|
|
2305
|
+
const { header } = session;
|
|
2306
|
+
if (header && (typeof header.delegationDepth === "number" && header.delegationDepth > 0 || header.origin === "subagent")) return;
|
|
2307
|
+
const digest = renderRulesDigest(store.rules(), project, messages);
|
|
2308
|
+
if (digest === null) return;
|
|
2309
|
+
agent.inject({
|
|
2310
|
+
id: brandString(`${PLUGIN_NAME}-${randomUUID()}`),
|
|
2311
|
+
role: "user",
|
|
2312
|
+
content: [{
|
|
2313
|
+
type: "text",
|
|
2314
|
+
text: digest
|
|
2315
|
+
}],
|
|
2316
|
+
source: {
|
|
2317
|
+
kind: LESSON_SOURCE_KIND,
|
|
2318
|
+
form: "instructions"
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
2321
|
+
console.info(`[lesson-loop] injected ${digest.split("\n").length - 1} armed rule(s) for project ${project}`);
|
|
2322
|
+
} catch (error) {
|
|
2323
|
+
console.error(`[lesson-loop] session-start inject failed: ${errorText(error)}`);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/** 页面策略声明:本包自带卡片,别让宿主再生成一份自动表单页。
|
|
2327
|
+
* 0.1.7 起注册是**隐式**的(命名空间 = profile 条目 id,见 cordis.patch.yml),可编辑
|
|
2328
|
+
* 字段由 schema 的 `.volatile()` 交出,插件侧不再有 `settings.register`;只剩这一位要
|
|
2329
|
+
* 声明。owner 必须显式传本插件 fiber(缺省是 settings 服务自己的 fiber,传错等于给别人的
|
|
2330
|
+
* 页面定策略),且经 child.effect 挂载以便随注入子上下文回收——宿主 dsh-client-locale
|
|
2331
|
+
* 与已迁移三包同款写法。 */
|
|
2332
|
+
function applySettingsPagePolicy(svc) {
|
|
2333
|
+
svc.inject(["settings"], (child) => {
|
|
2334
|
+
child.effect(() => child.settings.configure({ auto: false }, svc.fiber));
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
/** 现读一份解析后的设置(旧 `scope.get()` 的等价物):每次都重新取引用的当前值,所以
|
|
2338
|
+
* 设置卡改完下一个事件/回合即生效,不必重挂载插件。 */
|
|
2339
|
+
function settingsSnapshot(config) {
|
|
2340
|
+
return {
|
|
2341
|
+
enabled: config.enabled.get(),
|
|
2342
|
+
reportEnabled: config.reportEnabled.get(),
|
|
2343
|
+
injectEnabled: config.injectEnabled.get(),
|
|
2344
|
+
sectionEnabled: config.sectionEnabled.get(),
|
|
2345
|
+
promoteThreshold: config.promoteThreshold.get(),
|
|
2346
|
+
demoteThreshold: config.demoteThreshold.get(),
|
|
2347
|
+
demoteMinSamples: config.demoteMinSamples.get(),
|
|
2348
|
+
demoteRatio: config.demoteRatio.get(),
|
|
2349
|
+
decayDays: config.decayDays.get(),
|
|
2350
|
+
reviveThreshold: config.reviveThreshold.get(),
|
|
2351
|
+
maxLessonsBytes: config.maxLessonsBytes.get(),
|
|
2352
|
+
digestTimeoutMs: config.digestTimeoutMs,
|
|
2353
|
+
decayIntervalMs: config.decayIntervalMs
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
/** 建总线与规则引擎:装载期一次性取阈值给构造器,之后每次 report/decay 前都会 syncStore
|
|
2357
|
+
* 现读,改动不必重启。 */
|
|
2358
|
+
function buildLessonStore(svc, settingsOf, localeMessages) {
|
|
2359
|
+
const boot = settingsOf();
|
|
2360
|
+
return new LessonStore({
|
|
2361
|
+
lessonsFile: cacheFile("events.jsonl"),
|
|
2362
|
+
rules: createRulesFacet(svc.settings),
|
|
2363
|
+
messages: localeMessages,
|
|
2364
|
+
maxLessonsBytes: boot.maxLessonsBytes,
|
|
2365
|
+
promoteThreshold: boot.promoteThreshold,
|
|
2366
|
+
demoteThreshold: boot.demoteThreshold,
|
|
2367
|
+
demoteMinSamples: boot.demoteMinSamples,
|
|
2368
|
+
demoteRatio: boot.demoteRatio,
|
|
2369
|
+
decayDays: boot.decayDays,
|
|
2370
|
+
reviveThreshold: boot.reviveThreshold
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
/** 装载期一次性迁移:provider 写是异步的,apply 是同步契约 → 起一条链按旧顺序跑
|
|
2374
|
+
* (先收签名碎片、再重算 project 键),两个 helper 各自 catch,故链上不会 reject。 */
|
|
2375
|
+
function startBootMigrations(store) {
|
|
2376
|
+
(async () => {
|
|
2377
|
+
await runFragmentMigration(store);
|
|
2378
|
+
await runProjectKeyMigration(store);
|
|
2379
|
+
})();
|
|
2380
|
+
}
|
|
2381
|
+
/** 会话台账 + 它的容量修剪。
|
|
2382
|
+
* sessionId → {project, violated}:会话内复发记账,session/disposed 时清算 suppressed。
|
|
2383
|
+
* 容量纪律同 ctx-observe(超 200 丢最旧,防长驻进程无界)。 */
|
|
2384
|
+
function createSessionWatch() {
|
|
2385
|
+
const sessionWatch = /* @__PURE__ */ new Map();
|
|
2386
|
+
/**
|
|
2387
|
+
* 每次插入后调用:超出上限时按插入序把多出的几条丢掉。
|
|
2388
|
+
*
|
|
2389
|
+
* 不用「取最旧一条 + 跳过本会话」的写法:Map.set 对已存在的键保持原位,而每次
|
|
2390
|
+
* 插入后都会立刻回到 ≤ 上限,故能走到这里的调用一定是"新键排在末位"——按插入序
|
|
2391
|
+
* 丢的就是别的会话,永远丢不到刚被触碰的这条。留着那个 keepId 判断只会是一块
|
|
2392
|
+
* 不可达、测不到的死分支(同下面不写 while 的理由)。
|
|
2393
|
+
*/
|
|
2394
|
+
const pruneWatch = () => {
|
|
2395
|
+
const overflow = sessionWatch.size - WATCH_MAX_SESSIONS;
|
|
2396
|
+
if (overflow <= 0) return;
|
|
2397
|
+
for (const key of [...sessionWatch.keys()].slice(0, overflow)) sessionWatch.delete(key);
|
|
2398
|
+
};
|
|
2399
|
+
return {
|
|
2400
|
+
sessionWatch,
|
|
2401
|
+
pruneWatch
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
/** settings → store 的阈值同步(pull:每次 report/decay 前执行)。 */
|
|
2405
|
+
function syncStoreFrom(store, settingsOf) {
|
|
2406
|
+
const cfg = settingsOf();
|
|
2407
|
+
store.configure({
|
|
2408
|
+
promoteThreshold: cfg.promoteThreshold,
|
|
2409
|
+
demoteThreshold: cfg.demoteThreshold,
|
|
2410
|
+
demoteMinSamples: cfg.demoteMinSamples,
|
|
2411
|
+
demoteRatio: cfg.demoteRatio,
|
|
2412
|
+
decayDays: cfg.decayDays,
|
|
2413
|
+
reviveThreshold: cfg.reviveThreshold,
|
|
2414
|
+
maxLessonsBytes: cfg.maxLessonsBytes
|
|
2415
|
+
});
|
|
2416
|
+
return cfg;
|
|
2417
|
+
}
|
|
2418
|
+
/** 记一条教训。全同步、容错:总线任何故障不外抛(报告方多在守卫热路径上)。 */
|
|
2419
|
+
async function reportLesson(runtime, input) {
|
|
2420
|
+
const { store, sessionWatch, pruneWatch, syncStore } = runtime;
|
|
2421
|
+
try {
|
|
2422
|
+
const cfg = syncStore();
|
|
2423
|
+
if (!cfg.enabled || !cfg.reportEnabled) return {
|
|
2424
|
+
ok: false,
|
|
2425
|
+
reason: "disabled"
|
|
2426
|
+
};
|
|
2427
|
+
const project = deriveProject(input.cwd);
|
|
2428
|
+
const receipt = await store.report({
|
|
2429
|
+
ts: Date.now(),
|
|
2430
|
+
source: input.source,
|
|
2431
|
+
category: input.category,
|
|
2432
|
+
project,
|
|
2433
|
+
...input.cwd !== void 0 && typeof input.cwd === "string" ? { cwd: input.cwd } : {},
|
|
2434
|
+
...input.sessionId === void 0 ? {} : { sessionId: input.sessionId },
|
|
2435
|
+
...input.turn === void 0 ? {} : { turn: input.turn },
|
|
2436
|
+
signature: input.signature,
|
|
2437
|
+
detail: input.detail,
|
|
2438
|
+
...input.evidence === void 0 ? {} : { evidence: input.evidence }
|
|
2439
|
+
});
|
|
2440
|
+
if (input.sessionId !== void 0 && receipt.violationOf !== void 0) {
|
|
2441
|
+
const watchEntry = sessionWatch.get(input.sessionId) ?? {
|
|
2442
|
+
project,
|
|
2443
|
+
violated: /* @__PURE__ */ new Set(),
|
|
2444
|
+
passed: /* @__PURE__ */ new Set()
|
|
2445
|
+
};
|
|
2446
|
+
watchEntry.project = project;
|
|
2447
|
+
watchEntry.violated.add(receipt.violationOf.id);
|
|
2448
|
+
sessionWatch.set(input.sessionId, watchEntry);
|
|
2449
|
+
pruneWatch();
|
|
2450
|
+
}
|
|
2451
|
+
if (receipt.violationOf !== void 0) console.warn(`[lesson-loop] armed rule violated: ${receipt.violationOf.statement.slice(0, 80)}(violation=${receipt.violationOf.violation})`);
|
|
2452
|
+
else if (receipt.candidate !== void 0 && receipt.ready === true) console.info(`[lesson-loop] candidate ready for review: ${receipt.candidate.category} / ${receipt.candidate.signature}`);
|
|
2453
|
+
return receipt;
|
|
2454
|
+
} catch (error) {
|
|
2455
|
+
console.error(`[lesson-loop] report failed: ${errorText(error)}`);
|
|
2456
|
+
return {
|
|
2457
|
+
ok: false,
|
|
2458
|
+
reason: "error"
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
/** pass 信号:规则场景被触发且被遵守(如 danger-guard fact-gate
|
|
2463
|
+
* 通过)。登记到会话台账,session/disposed 清算时该规则才计 suppressed——
|
|
2464
|
+
* 无关会话不再灌水。仅 armed 规则消费。 */
|
|
2465
|
+
function recordPass(runtime, input) {
|
|
2466
|
+
const { store, sessionWatch, pruneWatch, syncStore } = runtime;
|
|
2467
|
+
try {
|
|
2468
|
+
if (!syncStore().enabled) return { ok: false };
|
|
2469
|
+
const project = deriveProject(input.cwd);
|
|
2470
|
+
const rule = store.pass(project, input.category, input.signature);
|
|
2471
|
+
if (rule === void 0) return { ok: true };
|
|
2472
|
+
if (input.sessionId !== void 0) {
|
|
2473
|
+
const watchEntry = sessionWatch.get(input.sessionId) ?? {
|
|
2474
|
+
project,
|
|
2475
|
+
violated: /* @__PURE__ */ new Set(),
|
|
2476
|
+
passed: /* @__PURE__ */ new Set()
|
|
2477
|
+
};
|
|
2478
|
+
watchEntry.project = project;
|
|
2479
|
+
watchEntry.passed.add(rule.id);
|
|
2480
|
+
sessionWatch.set(input.sessionId, watchEntry);
|
|
2481
|
+
pruneWatch();
|
|
2482
|
+
}
|
|
2483
|
+
return { ok: true };
|
|
2484
|
+
} catch (error) {
|
|
2485
|
+
console.error(`[lesson-loop] pass failed: ${errorText(error)}`);
|
|
2486
|
+
return { ok: false };
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
/** 管理面快照(/stats 的原样载荷 + 卡片副标题用的计数)。 */
|
|
2490
|
+
function statsSnapshot(runtime) {
|
|
2491
|
+
const { store, settingsOf } = runtime;
|
|
2492
|
+
const cfg = settingsOf();
|
|
2493
|
+
return {
|
|
2494
|
+
rules: store.rules(),
|
|
2495
|
+
lessonsCount: store.lessonsCount(),
|
|
2496
|
+
undeterminableCount: store.undeterminableRules().length,
|
|
2497
|
+
config: {
|
|
2498
|
+
enabled: cfg.enabled,
|
|
2499
|
+
reportEnabled: cfg.reportEnabled,
|
|
2500
|
+
injectEnabled: cfg.injectEnabled,
|
|
2501
|
+
sectionEnabled: cfg.sectionEnabled,
|
|
2502
|
+
promoteThreshold: cfg.promoteThreshold,
|
|
2503
|
+
demoteThreshold: cfg.demoteThreshold,
|
|
2504
|
+
decayDays: cfg.decayDays,
|
|
2505
|
+
maxLessonsBytes: cfg.maxLessonsBytes
|
|
2506
|
+
}
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
/** 装配对外服务面:六位里四位是 store 的直通,report/pass/stats 各走自己的 helper。 */
|
|
2510
|
+
function createLessonLoopService(runtime) {
|
|
2511
|
+
const { store } = runtime;
|
|
2512
|
+
return {
|
|
2513
|
+
report: (input) => reportLesson(runtime, input),
|
|
2514
|
+
pass: (input) => recordPass(runtime, input),
|
|
2515
|
+
rules() {
|
|
2516
|
+
return store.rules();
|
|
2517
|
+
},
|
|
2518
|
+
async ruleAction(id, action, statement) {
|
|
2519
|
+
return await store.ruleAction(id, action, statement);
|
|
2520
|
+
},
|
|
2521
|
+
recentLessons(project, limit) {
|
|
2522
|
+
return store.recentLessons(project, limit ?? 0);
|
|
2523
|
+
},
|
|
2524
|
+
stats: () => statsSnapshot(runtime)
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
/** 服务供给:inspector 同款(provide 返回 disposer 交 effect 托管)。
|
|
2528
|
+
* provide 缺失 = 总线不可见,报告方全部静默丢报告 → 一条 error 日志点破。 */
|
|
2529
|
+
function provideLessonLoopService(svc, service) {
|
|
2530
|
+
svc.effect(() => {
|
|
2531
|
+
if (typeof svc.provide !== "function") {
|
|
2532
|
+
console.error("[lesson-loop] ctx.provide unavailable — lessonLoop service invisible to reporters");
|
|
2533
|
+
return noDisposer;
|
|
2534
|
+
}
|
|
2535
|
+
return svc.provide("lessonLoop", service);
|
|
2536
|
+
}, "lesson-loop: provide lessonLoop service");
|
|
2537
|
+
}
|
|
2538
|
+
/** 会话开始:预种会话台账 + 注入 armed 规则摘要(见 onSessionStart)。
|
|
2539
|
+
* 事件接线修正:agent/session-start 无官方派发端,
|
|
2540
|
+
* 预种与 armed 规则注入从未执行过 → 改用官方 agent/created(载荷 {agent,source,signal})。 */
|
|
2541
|
+
function onAgentCreated(runtime, payloadRaw) {
|
|
2542
|
+
const { store, settingsOf, sessionWatch, pruneWatch, localeMessages } = runtime;
|
|
2543
|
+
const { agent } = payloadRaw;
|
|
2544
|
+
if (agent === void 0) return;
|
|
2545
|
+
onSessionStart({
|
|
2546
|
+
agent,
|
|
2547
|
+
settingsOf,
|
|
2548
|
+
store,
|
|
2549
|
+
sessionWatch,
|
|
2550
|
+
pruneWatch,
|
|
2551
|
+
messages: localeMessages()
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
/** 会话收尾清算:只对本会话 pass 过且未违规的 armed 规则计 suppressed(pass 信号
|
|
2555
|
+
* 驱动)。与上报同受 enabled 闸门:插件关闭期间不清算。 */
|
|
2556
|
+
function onDisposedSession(runtime, sessionRaw) {
|
|
2557
|
+
const { store, settingsOf, sessionWatch } = runtime;
|
|
2558
|
+
if (!settingsOf().enabled) return;
|
|
2559
|
+
const idVal = fieldOf(sessionRaw, "id");
|
|
2560
|
+
const sid = typeof idVal === "string" ? idVal : void 0;
|
|
2561
|
+
if (sid === void 0) return;
|
|
2562
|
+
const watchEntry = sessionWatch.get(sid);
|
|
2563
|
+
sessionWatch.delete(sid);
|
|
2564
|
+
if (watchEntry !== void 0) {
|
|
2565
|
+
settleQuietly(store.sessionEnded(watchEntry.project, watchEntry.violated, watchEntry.passed, sid), SESSION_ENDED_FAILURE_LABEL);
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
const header = fieldOf(sessionRaw, "header");
|
|
2569
|
+
const headerCwd = isRecord(header) ? fieldOf(header, "cwd") : void 0;
|
|
2570
|
+
settleQuietly(store.sessionEnded(deriveProject(headerCwd), /* @__PURE__ */ new Set(), /* @__PURE__ */ new Set(), sid), SESSION_ENDED_FAILURE_LABEL);
|
|
2571
|
+
}
|
|
2572
|
+
/** 两枚会话事件的接线(顺序即注册顺序,与拆出前一致)。 */
|
|
2573
|
+
function wireSessionEvents(runtime) {
|
|
2574
|
+
const { svc } = runtime;
|
|
2575
|
+
svc.on("agent/created", (payloadRaw) => {
|
|
2576
|
+
onAgentCreated(runtime, payloadRaw);
|
|
2577
|
+
});
|
|
2578
|
+
svc.on("session/disposed", (sessionRaw) => {
|
|
2579
|
+
onDisposedSession(runtime, sessionRaw);
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
/** 常驻 systemPrompt 段(项目无关;webServer/timer/commands 皆可选读)。
|
|
2583
|
+
* 开关用动态 text 而不是「注册时读一次」:section 注册在 effect 里只跑一次,
|
|
2584
|
+
* 读一次的写法要让设置卡改动生效必须重启。renderPrompt 会丢掉空 text 段
|
|
2585
|
+
* (core/system-prompt/src/index.ts:283),所以关闭时返回空串即完全不参与拼装。 */
|
|
2586
|
+
function registerPromptSection(runtime) {
|
|
2587
|
+
const { svc, settingsOf, localeMessages } = runtime;
|
|
2588
|
+
svc.effect(() => {
|
|
2589
|
+
const spRaw = svc.get?.("systemPrompt");
|
|
2590
|
+
if (!isSystemPromptService(spRaw)) return noDisposer;
|
|
2591
|
+
return spRaw.section({
|
|
2592
|
+
name: SECTION_NAME,
|
|
2593
|
+
order: SECTION_ORDER,
|
|
2594
|
+
text: () => settingsOf().sectionEnabled ? renderSectionText(localeMessages()) : ""
|
|
2595
|
+
});
|
|
2596
|
+
}, "lesson-loop: systemPrompt section");
|
|
2597
|
+
}
|
|
2598
|
+
/** 衰减一轮:阈值现读 + 跑 decay;失败只记日志(一轮炸了不许带走后面的轮次)。 */
|
|
2599
|
+
async function decayOnce(runtime) {
|
|
2600
|
+
const { store, syncStore } = runtime;
|
|
2601
|
+
try {
|
|
2602
|
+
syncStore();
|
|
2603
|
+
await store.runDecay();
|
|
2604
|
+
} catch (error) {
|
|
2605
|
+
console.error(`[lesson-loop] decay failed: ${errorText(error)}`);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
/** 周期衰减:apply 时立即跑一次,此后每 `decayIntervalMs`(部署值,默认 24h)一轮
|
|
2609
|
+
* (timer 可选读;缺失退化为主管人工)。 */
|
|
2610
|
+
function registerDecayTimer(runtime) {
|
|
2611
|
+
const { svc, settingsOf } = runtime;
|
|
2612
|
+
svc.effect(() => {
|
|
2613
|
+
const timerRaw = svc.get?.("timer");
|
|
2614
|
+
if (!isTimerService(timerRaw)) return noDisposer;
|
|
2615
|
+
const timer = timerRaw;
|
|
2616
|
+
const intervalMs = settingsOf().decayIntervalMs;
|
|
2617
|
+
decayOnce(runtime);
|
|
2618
|
+
let stopped = false;
|
|
2619
|
+
let last;
|
|
2620
|
+
const tick = () => {
|
|
2621
|
+
if (stopped) return;
|
|
2622
|
+
decayOnce(runtime);
|
|
2623
|
+
last = timer.timeout(tick, intervalMs);
|
|
2624
|
+
};
|
|
2625
|
+
last = timer.timeout(tick, intervalMs);
|
|
2626
|
+
return () => {
|
|
2627
|
+
stopped = true;
|
|
2628
|
+
last?.();
|
|
2629
|
+
};
|
|
2630
|
+
}, "lesson-loop: decay timer");
|
|
2631
|
+
}
|
|
2632
|
+
function apply(ctx, config) {
|
|
2633
|
+
if (!isHostCtx(ctx)) throw new Error("[lesson-loop] host context contract violated");
|
|
2634
|
+
const svc = ctx;
|
|
2635
|
+
applySettingsPagePolicy(svc);
|
|
2636
|
+
const settingsOf = () => settingsSnapshot(config);
|
|
2637
|
+
let cachedPreference = void 0;
|
|
2638
|
+
let hasCachedPreference = false;
|
|
2639
|
+
svc.on("settings/document-updated", (ns) => {
|
|
2640
|
+
if (ns === LOCALE_SETTINGS_NAMESPACE) hasCachedPreference = false;
|
|
2641
|
+
});
|
|
2642
|
+
const localeMessages = () => {
|
|
2643
|
+
if (!hasCachedPreference) {
|
|
2644
|
+
const row = svc.settings.describe().find((item) => fieldOf(item, "ns") === LOCALE_SETTINGS_NAMESPACE);
|
|
2645
|
+
if (row !== void 0) {
|
|
2646
|
+
cachedPreference = fieldOf(row, "value");
|
|
2647
|
+
hasCachedPreference = true;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
return messagesFor(MESSAGES, resolveLocalePreference(hasCachedPreference ? cachedPreference : void 0));
|
|
2651
|
+
};
|
|
2652
|
+
const store = buildLessonStore(svc, settingsOf, localeMessages);
|
|
2653
|
+
startBootMigrations(store);
|
|
2654
|
+
const { sessionWatch, pruneWatch } = createSessionWatch();
|
|
2655
|
+
const syncStore = () => syncStoreFrom(store, settingsOf);
|
|
2656
|
+
const runtime = {
|
|
2657
|
+
svc,
|
|
2658
|
+
store,
|
|
2659
|
+
settingsOf,
|
|
2660
|
+
localeMessages,
|
|
2661
|
+
sessionWatch,
|
|
2662
|
+
pruneWatch,
|
|
2663
|
+
syncStore
|
|
2664
|
+
};
|
|
2665
|
+
/** 对外服务面(danger-guard / quality-gate / session-rescue 经 ctx.get 消费)。 */
|
|
2666
|
+
const service = createLessonLoopService(runtime);
|
|
2667
|
+
provideLessonLoopService(svc, service);
|
|
2668
|
+
wireSessionEvents(runtime);
|
|
2669
|
+
svc.effect(() => settleOpenSessionsOnDispose({
|
|
2670
|
+
store,
|
|
2671
|
+
sessionWatch,
|
|
2672
|
+
settingsOf
|
|
2673
|
+
}), "lesson-loop: 退出清算");
|
|
2674
|
+
registerPromptSection(runtime);
|
|
2675
|
+
registerDigestCommand(svc, store, settingsOf, localeMessages);
|
|
2676
|
+
registerDecayTimer(runtime);
|
|
2677
|
+
svc.inject(["webServer"], (child) => {
|
|
2678
|
+
registerRoutes(svc, child, store, () => service.stats(), localeMessages);
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
var host_default = {
|
|
2682
|
+
inject: ["settings"],
|
|
2683
|
+
Config: configSchema,
|
|
2684
|
+
apply
|
|
2685
|
+
};
|
|
2686
|
+
//#endregion
|
|
2687
|
+
export { configSchema as Config, apply, cacheFile, host_default as default, isSettingsProvider };
|