@kira905/dsh-memory-toolkit 0.1.3 → 0.2.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.en.md +225 -2
  3. package/README.md +180 -2
  4. package/lib/deploy-guard.mjs +463 -275
  5. package/lib/guard-http.mjs +59 -62
  6. package/lib/pitfall-form-map.mjs +94 -84
  7. package/lib/pitfall-score.mjs +262 -185
  8. package/lib/session-file-name.mjs +116 -100
  9. package/lib/zstd-frames.mjs +88 -67
  10. package/memory-db-tools/README.md +29 -1
  11. package/memory-db-tools/audit-analyze.mjs +1 -1
  12. package/memory-db-tools/audit-export.mjs +60 -16
  13. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  14. package/memory-db-tools/lib/known-devices.mjs +137 -0
  15. package/memory-engine/README.md +58 -4
  16. package/memory-engine/bench/bench.mjs +1 -1
  17. package/memory-engine/cli/band-sentinel.mjs +164 -0
  18. package/memory-engine/cli/distill-rollback.mjs +112 -44
  19. package/memory-engine/cli/distill-sessions.mjs +237 -0
  20. package/memory-engine/cli/distill.mjs +196 -95
  21. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  22. package/memory-engine/cli/doc-index.mjs +47 -38
  23. package/memory-engine/cli/domain-tags.mjs +246 -162
  24. package/memory-engine/cli/layer-migrate.mjs +176 -0
  25. package/memory-engine/cli/layer-move.mjs +189 -0
  26. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  27. package/memory-engine/cli/recall.mjs +243 -110
  28. package/memory-engine/cli/selfcheck.mjs +93 -44
  29. package/memory-engine/cli/serve-start.mjs +221 -140
  30. package/memory-engine/cli/sweep.mjs +83 -44
  31. package/memory-engine/cli/sync.mjs +93 -57
  32. package/memory-engine/config/audit-devices.json +6 -0
  33. package/memory-engine/config/bands.json +7 -0
  34. package/memory-engine/config/layers.json +7 -0
  35. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  36. package/memory-engine/dict/lexicon-config.json +11 -0
  37. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  38. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  39. package/memory-engine/lib/band-sentinel.mjs +417 -0
  40. package/memory-engine/lib/cli-strict.mjs +128 -0
  41. package/memory-engine/lib/cli-util.mjs +147 -125
  42. package/memory-engine/lib/config.mjs +83 -96
  43. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  44. package/memory-engine/lib/distill-rollback.mjs +173 -133
  45. package/memory-engine/lib/distill.mjs +878 -585
  46. package/memory-engine/lib/doc-index-store.mjs +208 -235
  47. package/memory-engine/lib/doc-index.mjs +351 -350
  48. package/memory-engine/lib/doc-scan.mjs +178 -156
  49. package/memory-engine/lib/doc-summarize.mjs +220 -205
  50. package/memory-engine/lib/domain-tags.mjs +358 -277
  51. package/memory-engine/lib/embedder.mjs +175 -194
  52. package/memory-engine/lib/hashes.mjs +22 -7
  53. package/memory-engine/lib/layer-migrate.mjs +330 -0
  54. package/memory-engine/lib/layer-move.mjs +307 -0
  55. package/memory-engine/lib/layers.mjs +191 -0
  56. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  57. package/memory-engine/lib/memory-source.mjs +78 -80
  58. package/memory-engine/lib/pitfall-source.mjs +115 -114
  59. package/memory-engine/lib/policy-source.mjs +279 -261
  60. package/memory-engine/lib/recall-log.mjs +285 -0
  61. package/memory-engine/lib/recall.mjs +565 -240
  62. package/memory-engine/lib/selfcheck.mjs +142 -106
  63. package/memory-engine/lib/session-distill.mjs +603 -0
  64. package/memory-engine/lib/session-source.mjs +421 -0
  65. package/memory-engine/lib/sweep.mjs +107 -130
  66. package/memory-engine/lib/sync.mjs +110 -140
  67. package/memory-engine/lib/tick-hook.mjs +123 -148
  68. package/memory-engine/lib/vectors-store.mjs +213 -188
  69. package/memory-engine/test/12-layering.test.mjs +539 -0
  70. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  71. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  72. package/memory-engine/tools/fetch-model.mjs +167 -87
  73. package/memory-engine/tools/smoke-recall.mjs +94 -38
  74. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  75. package/package.json +1 -1
@@ -1,185 +1,262 @@
1
- // dsh-butler / lib/pitfall-score.mjs —— P4 动态权重 + 阶梯升级(纯函数,R 回归 golden 值)
2
- //
3
- // 方案 §4 P4:
4
- // 动态权重:score = count × 0.5^(距上次踩坑天数 / 30)
5
- // 30 天没再踩 → 权重减半、逐渐沉底,标「疑似已修复」(不删除)
6
- // 阶梯升级:1 次入库 / 2 次上浮 / ≥3 次且跨 ≥2 会话 → 生成候选
7
- // 候选三形态:①做成自动检查项(最优)②内联进相关技能 ③铁律级才动 persona
8
- // count 口径 = 同签名总出现次数;权重天数取末次出现时间(lastSeen)—— 均进 golden dataset
9
-
10
- import { lookupFormByMap } from './pitfall-form-map.mjs';
11
-
12
- // 0.2.0(2026-09-12):suggestForm 先查「错误码/签名 → 形态」映射表(lib/pitfall-form-map.mjs),
13
- // 未命中才回落原文本启发式。起因:AUDIT_NOT_PASSED(真实成因=没走 P5 审计)被猜成「路径存在性预检」。
14
- export const PITFALL_SCORE_VERSION = '0.2.0';
15
-
16
- /** 权重半衰期(天)。 */
17
- export const HALF_LIFE_DAYS = 30;
18
- /** 「疑似已修复」判定:距末次出现 ≥ 该天数。 */
19
- export const LIKELY_FIXED_DAYS = 30;
20
- /** 升级候选门槛:次数 ≥ 3 且跨 ≥ 2 会话。 */
21
- export const CANDIDATE_MIN_COUNT = 3;
22
- export const CANDIDATE_MIN_SESSIONS = 2;
23
- /** 坑库收录门槛:≥ 2 次(方案「门槛 ≥2」)。 */
24
- export const PITFALL_MIN_COUNT = 2;
25
- /** seed 条目(历史文字坑)的基准权重:不计 count,只进权重衰减池。 */
26
- export const SEED_BASE_SCORE = 2;
27
-
28
- const DAY_MS = 86_400_000;
29
-
30
- /**
31
- * 坑的权重:scan 条目 = count × 衰减;seed 条目 = 基准权重 × 衰减(不计 count)。
32
- * @param {object} p
33
- * @param {number} nowMs
34
- */
35
- export function scoreOf(p, nowMs) {
36
- if (p?.source === 'seed') {
37
- const base = Number(p.seedBase ?? SEED_BASE_SCORE) || SEED_BASE_SCORE;
38
- return Math.round(base * Math.pow(0.5, daysSince(p.lastSeen, nowMs) / HALF_LIFE_DAYS) * 10000) / 10000;
39
- }
40
- return decayScore(p?.count, p?.lastSeen, nowMs);
41
- }
42
-
43
- /**
44
- * 动态权重:score = count × 0.5^(距上次踩坑天数 / 30)。
45
- * @param {number} count 同签名总次数
46
- * @param {number} lastSeenMs 末次出现时间(epoch ms)
47
- * @param {number} nowMs
48
- * @param {number} [halfLifeDays]
49
- * @returns {number} 保留 4 位小数
50
- */
51
- export function decayScore(count, lastSeenMs, nowMs, halfLifeDays = HALF_LIFE_DAYS) {
52
- const c = Number(count) || 0;
53
- if (!c) return 0;
54
- const days = daysSince(lastSeenMs, nowMs);
55
- const raw = c * Math.pow(0.5, days / halfLifeDays);
56
- return Math.round(raw * 10000) / 10000;
57
- }
58
-
59
- /** 距末次出现的天数(负数按 0 处理;无时间戳按 0)。 */
60
- export function daysSince(lastSeenMs, nowMs) {
61
- const t = Number(lastSeenMs);
62
- if (!Number.isFinite(t) || t <= 0) return 0;
63
- const d = (Number(nowMs) - t) / DAY_MS;
64
- return d > 0 ? d : 0;
65
- }
66
-
67
- /** 是否「疑似已修复」(30 天没再踩,不删除,只沉底标注)。 */
68
- export function isLikelyFixed(lastSeenMs, nowMs, days = LIKELY_FIXED_DAYS) {
69
- return daysSince(lastSeenMs, nowMs) >= days;
70
- }
71
-
72
- /**
73
- * 阶梯形态(方案表)。
74
- * @returns {'recorded'|'surfaced'|'candidate'}
75
- */
76
- export function ladderStage(count, sessionCount) {
77
- const c = Number(count) || 0;
78
- const s = Number(sessionCount) || 0;
79
- if (c >= CANDIDATE_MIN_COUNT && s >= CANDIDATE_MIN_SESSIONS) return 'candidate';
80
- if (c >= PITFALL_MIN_COUNT) return 'surfaced';
81
- return 'recorded';
82
- }
83
-
84
- /** 该坑是否够格进坑库(门槛 ≥2 次;seed 条目豁免,见 source)。 */
85
- export function isRecordable(pitfall) {
86
- if (pitfall?.source === 'seed') return true;
87
- return (Number(pitfall?.count) || 0) >= PITFALL_MIN_COUNT;
88
- }
89
-
90
- /**
91
- * 排序:score 降序 → count 降序 → lastSeen 降序 → id 升序(稳定、可测)。
92
- * @param {object[]} pitfalls
93
- * @param {number} nowMs
94
- * @returns {object[]} 新数组(附 score / stage / likelyFixed / daysSinceLast)
95
- */
96
- export function rankPitfalls(pitfalls, nowMs) {
97
- const rows = (pitfalls ?? []).map((p) => {
98
- const sessionCount = Number(p.sessionCount ?? (Array.isArray(p.sessions) ? p.sessions.length : 0));
99
- return {
100
- ...p,
101
- sessionCount,
102
- score: scoreOf(p, nowMs),
103
- stage: ladderStage(p.count, sessionCount),
104
- likelyFixed: isLikelyFixed(p.lastSeen, nowMs),
105
- daysSinceLast: Math.round(daysSince(p.lastSeen, nowMs) * 10) / 10,
106
- };
107
- });
108
- rows.sort((a, b) => (
109
- b.score - a.score
110
- || (Number(b.count) || 0) - (Number(a.count) || 0)
111
- || (Number(b.lastSeen) || 0) - (Number(a.lastSeen) || 0)
112
- || String(a.id).localeCompare(String(b.id))
113
- ));
114
- return rows;
115
- }
116
-
117
- /** 开工 Top N(跳过疑似已修复;若全被跳过则返回原始前 N 并标注)。 */
118
- export function topN(pitfalls, n, nowMs) {
119
- const ranked = rankPitfalls(pitfalls, nowMs).filter((p) => !p.excluded);
120
- const fresh = ranked.filter((p) => !p.likelyFixed);
121
- const pick = (fresh.length ? fresh : ranked).slice(0, n);
122
- return pick;
123
- }
124
-
125
- /**
126
- * 建议的升级形态(方案:①自动检查项最优 ②内联技能 ③铁律级才动 persona)。
127
- *
128
- * 判定顺序(2026-09-12 起):
129
- * ① 已知错误码 / 签名 → **映射表**(lib/pitfall-form-map.mjs)优先命中 —— 成因确定的坑不交给启发式猜
130
- * ② 未命中 → 回落下面的文本启发式(行为与改动前一致)
131
- *
132
- * 注:`FS_NOT_OBSERVED / FS_STALE_VERSION` 原在此处被判 `check`,实测方向不对(它们是全局铁律、
133
- * 已进 persona),2026-09-12 升进映射表判 `persona`;`FS_SANDBOX_DENIED` 等仍走启发式。
134
- */
135
- export function suggestForm(pitfall) {
136
- const mapped = lookupFormByMap(pitfall);
137
- if (mapped) return { form: mapped.form, why: mapped.why, ruleId: mapped.ruleId, via: mapped.via };
138
-
139
- const cat = pitfall?.category ?? '其他';
140
- const sig = String(pitfall?.sigKey ?? '');
141
- const text = String(pitfall?.normText ?? pitfall?.sigKey ?? '');
142
- // 可脚本化检测的坑 → 优先「自动检查项」
143
- if (/FS_SANDBOX_DENIED/.test(sig)) return { form: 'check', why: '沙箱拒绝=权限配置问题,可做成"写生产路径前先验权限"的检查项' };
144
- if (/FS_NOT_TEXT|FS_NOT_FOUND/.test(sig)) return { form: 'check', why: '读文件前可先验类型/存在性,适合做成检查项' };
145
- if (cat === '守护鱼') return { form: 'check', why: '守护鱼类坑适合做成只读健康检查项(dsh-selfcheck 同款)' };
146
- if (cat === '插件') return { form: 'skill', why: '插件类坑适合内联进 plugin-deploy 技能开头' };
147
- if (cat === '同步') return { form: 'skill', why: '同步类坑适合内联进同步/备份相关技能或脚本注释' };
148
- if (cat === '会话') return { form: 'skill', why: '会话类坑适合内联进 session-cleanup / dsh-restart 技能' };
149
- if (cat === 'pwsh') return { form: 'skill', why: 'pwsh 类坑适合内联进相关技能;若能脚本化则优先检查项' };
150
- if (/路径|path|<PATH>/i.test(text)) return { form: 'check', why: '路径假设类坑可做成"路径存在性预检"检查项' };
151
- return { form: 'skill', why: '默认建议内联技能;只有反复踩且影响全局的铁律级才动 persona' };
152
- }
153
-
154
- /**
155
- * 生成升级候选(≥3 次且跨 ≥2 会话;**永不自动改 persona / 技能**)。
156
- * @param {object} pitfall 已 rank 的坑(含 score/stage)
157
- * @returns {object|null}
158
- */
159
- export function buildCandidate(pitfall, nowMs = Date.now()) {
160
- if (!pitfall || pitfall.source === 'seed') return null;
161
- if (ladderStage(pitfall.count, pitfall.sessionCount ?? 0) !== 'candidate') return null;
162
- const sug = suggestForm(pitfall);
163
- const options = [
164
- { id: 'check', label: '① 做成自动检查项(最优:坑不可能再犯)', hint: sug.form === 'check' ? sug.why : '若可脚本化判定则首选' },
165
- { id: 'skill', label: '② 内联进相关技能(加载即见)', hint: sug.form === 'skill' ? sug.why : `建议内联进与「${pitfall.category}」相关的技能开头` },
166
- { id: 'persona', label: '③ 铁律级才动 persona(需 persona-maintain 流程)', hint: '仅当该坑影响全局且反复踩时才考虑;采纳后必须走 validate-persona 验证' },
167
- ];
168
- return {
169
- id: pitfall.id,
170
- pitfallId: pitfall.id,
171
- sigKey: pitfall.sigKey,
172
- category: pitfall.category,
173
- count: pitfall.count,
174
- sessionCount: pitfall.sessionCount,
175
- score: pitfall.score ?? decayScore(pitfall.count, pitfall.lastSeen, nowMs),
176
- tools: pitfall.tools ?? {},
177
- normText: pitfall.normText ?? '',
178
- suggested: sug.form,
179
- why: sug.why,
180
- options,
181
- status: 'pending',
182
- createdAt: new Date(nowMs).toISOString(),
183
- decidedAt: null,
184
- };
185
- }
1
+ // 坑的权重与升级阶梯 —— 全是纯函数,给定输入就给定输出(回归时有 golden 值盯着)。
2
+ //
3
+ // 两个概念:
4
+ // 权重:踩得越勤越重,但会随时间自然衰减 —— count × 0.5^(距上次踩坑的天数 / 半衰期)。
5
+ // 沉下去不等于删掉:超过一个半衰期没再踩,只标"疑似已修复",条目仍留在库里。
6
+ // 阶梯:踩 1 次入库 / 2 次上浮 / 3 次以上且跨 2 个会话才生成升级候选。
7
+ // 候选有三种落点(自动检查项 → 内联技能 → persona 铁律),越靠后越重;
8
+ // 本件只负责"建议哪一种",从不自己动手改 persona 或技能。
9
+ //
10
+ // 形态判定先查映射表(同目录的 pitfall-form-map.mjs):成因确定的坑交给表,表里没有才回落到
11
+ // 按文本特征猜。起因很实在 —— 有类坑的文本里净是路径,一猜就猜成"路径存在性预检",
12
+ // 方向完全反了。
13
+ //
14
+ // 口径约定:count = 同签名的总出现次数;衰减的天数取末次出现时间(lastSeen)。
15
+
16
+ import { lookupFormByMap } from './pitfall-form-map.mjs';
17
+
18
+ // 本版起 suggestForm 换了判定顺序:先查表、再启发式(表在本件之外,见上面的 import)。
19
+ export const PITFALL_SCORE_VERSION = '0.2.0';
20
+
21
+ /** 权重半衰期(天):每过这么多天,权重乘 1/2 */
22
+ export const HALF_LIFE_DAYS = 30;
23
+ /** 距末次出现超过这么多天 → 标"疑似已修复"(只沉底,不删除) */
24
+ export const LIKELY_FIXED_DAYS = 30;
25
+ /** 升级候选的两道门槛:出现次数、跨会话数 */
26
+ export const CANDIDATE_MIN_COUNT = 3;
27
+ export const CANDIDATE_MIN_SESSIONS = 2;
28
+ /** 够格进坑库的门槛(seed 条目另算) */
29
+ export const PITFALL_MIN_COUNT = 2;
30
+ /** seed 条目(历史文字坑)的基准权重:不数 count,只进衰减池 */
31
+ export const SEED_BASE_SCORE = 2;
32
+
33
+ const DAY_MS = 86_400_000;
34
+ const ROUND_SCALE = 10000;
35
+ const DECAY_BASE = 0.5;
36
+
37
+ // 权重统一保留 4 位小数:先放大再取整再缩回,避免 0.1+0.2 式的尾巴进报告
38
+ function round4(value) {
39
+ return Math.round(value * ROUND_SCALE) / ROUND_SCALE;
40
+ }
41
+
42
+ /**
43
+ * 距上次踩坑过了多少天。
44
+ * 没有时间戳、时间戳非法、或者算出来是负数(时钟回拨)一律按 0 天处理。
45
+ * @param {number} lastSeenMs
46
+ * @param {number} nowMs
47
+ * @returns {number}
48
+ */
49
+ export function daysSince(lastSeenMs, nowMs) {
50
+ const seen = Number(lastSeenMs);
51
+ if (!Number.isFinite(seen) || seen <= 0) return 0;
52
+ const elapsed = (Number(nowMs) - seen) / DAY_MS;
53
+ return elapsed > 0 ? elapsed : 0;
54
+ }
55
+
56
+ /**
57
+ * 动态权重:count × 0.5^(天数 / 半衰期)。
58
+ * @param {number} count 同签名总次数
59
+ * @param {number} lastSeenMs 末次出现时间(epoch ms)
60
+ * @param {number} nowMs
61
+ * @param {number} [halfLifeDays]
62
+ * @returns {number} 保留 4 位小数;count 为 0 直接 0
63
+ */
64
+ export function decayScore(count, lastSeenMs, nowMs, halfLifeDays = HALF_LIFE_DAYS) {
65
+ const occurrences = Number(count) || 0;
66
+ if (occurrences === 0) return 0;
67
+ const factor = Math.pow(DECAY_BASE, daysSince(lastSeenMs, nowMs) / halfLifeDays);
68
+ return round4(occurrences * factor);
69
+ }
70
+
71
+ /**
72
+ * 一条坑的当前权重。两种来源分开算:
73
+ * scan 条目 —— count 走衰减;
74
+ * seed 条目 —— 没有 count,用基准权重(seedBase)走衰减。
75
+ * @param {object} pitfall
76
+ * @param {number} nowMs
77
+ * @returns {number}
78
+ */
79
+ export function scoreOf(pitfall, nowMs) {
80
+ if (pitfall?.source === 'seed') {
81
+ const baseline = Number(pitfall.seedBase ?? SEED_BASE_SCORE) || SEED_BASE_SCORE;
82
+ return round4(baseline * Math.pow(DECAY_BASE, daysSince(pitfall.lastSeen, nowMs) / HALF_LIFE_DAYS));
83
+ }
84
+ return decayScore(pitfall?.count, pitfall?.lastSeen, nowMs);
85
+ }
86
+
87
+ /** 是否"疑似已修复"(够久没再踩;不删条目,只是沉底并打标) */
88
+ export function isLikelyFixed(lastSeenMs, nowMs, days = LIKELY_FIXED_DAYS) {
89
+ return daysSince(lastSeenMs, nowMs) >= days;
90
+ }
91
+
92
+ /**
93
+ * 阶梯档位。
94
+ * @returns {'recorded'|'surfaced'|'candidate'}
95
+ */
96
+ export function ladderStage(count, sessionCount) {
97
+ const hits = Number(count) || 0;
98
+ const sessions = Number(sessionCount) || 0;
99
+ if (hits >= CANDIDATE_MIN_COUNT && sessions >= CANDIDATE_MIN_SESSIONS) return 'candidate';
100
+ return hits >= PITFALL_MIN_COUNT ? 'surfaced' : 'recorded';
101
+ }
102
+
103
+ /** 够不够格进坑库:seed 条目豁免(它是人工挑进去的历史文字坑) */
104
+ export function isRecordable(pitfall) {
105
+ if (pitfall?.source === 'seed') return true;
106
+ return (Number(pitfall?.count) || 0) >= PITFALL_MIN_COUNT;
107
+ }
108
+
109
+ // 会话数:优先认显式字段,没有就数 sessions 数组的长度;两种情况都过一遍 Number
110
+ function sessionTally(pitfall) {
111
+ const explicit = pitfall.sessionCount;
112
+ if (explicit === undefined || explicit === null) {
113
+ return Array.isArray(pitfall.sessions) ? pitfall.sessions.length : 0;
114
+ }
115
+ return Number(explicit);
116
+ }
117
+
118
+ // 排序规则写成一个比较器:权重降序 → 次数降序 → 末次时间降序 → id 升序(保证可复现)
119
+ function rankOrder(left, right) {
120
+ return right.score - left.score
121
+ || (Number(right.count) || 0) - (Number(left.count) || 0)
122
+ || (Number(right.lastSeen) || 0) - (Number(left.lastSeen) || 0)
123
+ || String(left.id).localeCompare(String(right.id));
124
+ }
125
+
126
+ /**
127
+ * 排序并补齐派生字段。
128
+ * @param {object[]} pitfalls
129
+ * @param {number} nowMs
130
+ * @returns {object[]} 新数组(附 score / stage / likelyFixed / daysSinceLast)
131
+ */
132
+ export function rankPitfalls(pitfalls, nowMs) {
133
+ const decorated = (pitfalls ?? []).map((pitfall) => {
134
+ const sessionCount = sessionTally(pitfall);
135
+ return {
136
+ ...pitfall,
137
+ sessionCount,
138
+ score: scoreOf(pitfall, nowMs),
139
+ stage: ladderStage(pitfall.count, sessionCount),
140
+ likelyFixed: isLikelyFixed(pitfall.lastSeen, nowMs),
141
+ daysSinceLast: Math.round(daysSince(pitfall.lastSeen, nowMs) * 10) / 10,
142
+ };
143
+ });
144
+ decorated.sort(rankOrder);
145
+ return decorated;
146
+ }
147
+
148
+ /**
149
+ * 开工前看一眼的 Top N:跳过被排除的;全是"疑似已修复"时退回原始前 N(免得空手)。
150
+ * @param {object[]} pitfalls
151
+ * @param {number} n
152
+ * @param {number} nowMs
153
+ * @returns {object[]}
154
+ */
155
+ export function topN(pitfalls, n, nowMs) {
156
+ const usable = rankPitfalls(pitfalls, nowMs).filter((row) => !row.excluded);
157
+ const fresh = usable.filter((row) => !row.likelyFixed);
158
+ return (fresh.length ? fresh : usable).slice(0, n);
159
+ }
160
+
161
+ // 没有稳定错误码、只能靠签名特征认的坑(按顺序试,先命中者胜)
162
+ const SIGNATURE_FORMS = [
163
+ {
164
+ pattern: /FS_SANDBOX_DENIED/,
165
+ form: 'check',
166
+ why: '沙箱拒绝=权限配置问题,可做成"写生产路径前先验权限"的检查项',
167
+ },
168
+ {
169
+ pattern: /FS_NOT_TEXT|FS_NOT_FOUND/,
170
+ form: 'check',
171
+ why: '读文件前可先验类型/存在性,适合做成检查项',
172
+ },
173
+ ];
174
+
175
+ // 按分类给形态:分类信息只有一层,逐条 if 不如查表
176
+ const CATEGORY_FORMS = new Map([
177
+ ['守护鱼', { form: 'check', why: '守护鱼类坑适合做成只读健康检查项(dsh-selfcheck 同款)' }],
178
+ ['插件', { form: 'skill', why: '插件类坑适合内联进 plugin-deploy 技能开头' }],
179
+ ['同步', { form: 'skill', why: '同步类坑适合内联进同步/备份相关技能或脚本注释' }],
180
+ ['会话', { form: 'skill', why: '会话类坑适合内联进 session-cleanup / dsh-restart 技能' }],
181
+ ['pwsh', { form: 'skill', why: 'pwsh 类坑适合内联进相关技能;若能脚本化则优先检查项' }],
182
+ ]);
183
+
184
+ const PATH_HINT_RE = /路径|path|<PATH>/i;
185
+ const PATH_FORM = { form: 'check', why: '路径假设类坑可做成"路径存在性预检"检查项' };
186
+ const FALLBACK_FORM = { form: 'skill', why: '默认建议内联技能;只有反复踩且影响全局的铁律级才动 persona' };
187
+
188
+ /**
189
+ * 建议的升级形态。
190
+ * 判定顺序:① 映射表(错误码 / 签名)命中即用 → ② 签名特征 → ③ 分类 → ④ 文本里提到路径 → ⑤ 默认。
191
+ * 前两步之外都是启发式,只在前面的都认不出来时才轮到。
192
+ * @param {object} pitfall
193
+ * @returns {{form:'check'|'skill'|'persona', why:string, ruleId?:string, via?:string}}
194
+ */
195
+ export function suggestForm(pitfall) {
196
+ const fromMap = lookupFormByMap(pitfall);
197
+ if (fromMap) return { form: fromMap.form, why: fromMap.why, ruleId: fromMap.ruleId, via: fromMap.via };
198
+
199
+ const signature = String(pitfall?.sigKey ?? '');
200
+ for (const entry of SIGNATURE_FORMS) {
201
+ if (entry.pattern.test(signature)) return { form: entry.form, why: entry.why };
202
+ }
203
+
204
+ const byCategory = CATEGORY_FORMS.get(pitfall?.category ?? '其他');
205
+ if (byCategory) return { form: byCategory.form, why: byCategory.why };
206
+
207
+ const body = String(pitfall?.normText ?? pitfall?.sigKey ?? '');
208
+ const chosen = PATH_HINT_RE.test(body) ? PATH_FORM : FALLBACK_FORM;
209
+ return { ...chosen };
210
+ }
211
+
212
+ // 三个选项的文案固定:谁排第一、哪个被标"最优",都是方案定死的,不按输入变
213
+ function candidateOptions(suggestion, category) {
214
+ return [
215
+ {
216
+ id: 'check',
217
+ label: '① 做成自动检查项(最优:坑不可能再犯)',
218
+ hint: suggestion.form === 'check' ? suggestion.why : '若可脚本化判定则首选',
219
+ },
220
+ {
221
+ id: 'skill',
222
+ label: '② 内联进相关技能(加载即见)',
223
+ hint: suggestion.form === 'skill' ? suggestion.why : `建议内联进与「${category}」相关的技能开头`,
224
+ },
225
+ {
226
+ id: 'persona',
227
+ label: '③ 铁律级才动 persona(需 persona-maintain 流程)',
228
+ hint: '仅当该坑影响全局且反复踩时才考虑;采纳后必须走 validate-persona 验证',
229
+ },
230
+ ];
231
+ }
232
+
233
+ /**
234
+ * 生成升级候选(次数 ≥3 且跨 ≥2 会话)。
235
+ * 只是"候选":本函数永不改 persona / 技能,落不落、落哪里由人拍。
236
+ * @param {object} pitfall 已 rank 过的坑(最好带 score/stage)
237
+ * @param {number} [nowMs]
238
+ * @returns {object|null} 不够格 / seed 条目 → null
239
+ */
240
+ export function buildCandidate(pitfall, nowMs = Date.now()) {
241
+ if (!pitfall || pitfall.source === 'seed') return null;
242
+ if (ladderStage(pitfall.count, pitfall.sessionCount ?? 0) !== 'candidate') return null;
243
+
244
+ const suggestion = suggestForm(pitfall);
245
+ return {
246
+ id: pitfall.id,
247
+ pitfallId: pitfall.id,
248
+ sigKey: pitfall.sigKey,
249
+ category: pitfall.category,
250
+ count: pitfall.count,
251
+ sessionCount: pitfall.sessionCount,
252
+ score: pitfall.score ?? decayScore(pitfall.count, pitfall.lastSeen, nowMs),
253
+ tools: pitfall.tools ?? {},
254
+ normText: pitfall.normText ?? '',
255
+ suggested: suggestion.form,
256
+ why: suggestion.why,
257
+ options: candidateOptions(suggestion, pitfall.category),
258
+ status: 'pending',
259
+ createdAt: new Date(nowMs).toISOString(),
260
+ decidedAt: null,
261
+ };
262
+ }