dsh-experience 1.2.0 → 1.2.2
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/CHANGELOG.md +41 -0
- package/index.js +64 -9
- package/lib/capture.js +59 -12
- package/lib/decay.js +28 -7
- package/lib/recall.js +10 -0
- package/lib/store.js +51 -18
- package/package.json +1 -1
- package/tools/e2e.mjs +17 -0
- package/tools/selftest.mjs +120 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## [1.2.2] — 2026-11-09 — 体验修复:采集可靠性、候选可读性、旧经验降权
|
|
5
|
+
|
|
6
|
+
**采集**
|
|
7
|
+
- `tools/result` 与 turn 生命周期统一用 session id 对齐(原先工具侧取 `agent.id`、轮末取 `session.id`,不一致时整轮调用被记到另一个 key,表现为 `G1: 成功工具调用 < 2` 而实际跑了很多工具)。
|
|
8
|
+
- 单 key 兜底搬运 + `trace.resultsSeen` / `trace.keyFallback`;G1 拒绝但确实收到过结果时写入 `capture.diagnostic`,不再静默。
|
|
9
|
+
|
|
10
|
+
**召回**
|
|
11
|
+
- 无 platform 标签的历史记录排序 ×0.85,注入块里标注「平台:未标注」。
|
|
12
|
+
- `score()` 收到非字符串直接抛 `TypeError`(此前把记录对象传进去会静默得 0,看起来像"不相关")。
|
|
13
|
+
|
|
14
|
+
**候选可读性**
|
|
15
|
+
- 标题带项目名:`新增 truncate(acme-app)`、`跑 run(cross-a)`。
|
|
16
|
+
- 触发条件写真实验证命令,不再三条候选共用一句模板。
|
|
17
|
+
- `stepLabel` 去掉 `cd …;` 前缀;`cd` / `Start-Sleep` 计入噪声。
|
|
18
|
+
- 新增 `/memory review discard`:一次性拒绝当前所有候选(可审计,非删除)。
|
|
19
|
+
|
|
20
|
+
**衰减**
|
|
21
|
+
- 容量未达阈值时不再输出"观察项"清单(小库时那 5 条就是全部,纯噪声)。
|
|
22
|
+
|
|
23
|
+
**测试**:`selftest 106` + `e2e 25` = 131 全部通过。
|
|
24
|
+
|
|
25
|
+
**已知限制**:采集可靠性改动只经过单测与结构验证;真实轮次的效果需要下一个长轮次的 `capture.diagnostic` 来确认。
|
|
26
|
+
|
|
27
|
+
## [1.2.1] — 2026-11-09 — 审计修复:召回语料、id 回收、模拟淘汰、恢复、G8 守卫
|
|
28
|
+
|
|
29
|
+
### 修复
|
|
30
|
+
- **召回漏传 IDF 语料**:`index.js` 召回处只传两个参数,`scoreExperience` 的第三参为空,所有词权重 1,常见词与稀有词同权——无关记录可能越过阈值被注入。现在与合并路径一致。
|
|
31
|
+
- **`nextId` 不与现有 id 对账**:载入时与 `experiences`/`archive` 的最大 `exp-N` 对齐,不再复用已存在的 id(否则 `forget`/`archive` 会打到旧记录)。
|
|
32
|
+
- **`decay --age N --apply` 用模拟未来时间真归档**:现在直接拒绝,`--age` 只用于展示。
|
|
33
|
+
- **`restore` 是空操作**:恢复时把 `lastUsedAt` 置为当前时刻,记录不会立刻重新判为 dormant。
|
|
34
|
+
- **淘汰会毁掉 G8 守卫**:`superseded` 不再进入淘汰候选(归档后同一指纹会被重新收录)。
|
|
35
|
+
- **“没什么可抓”被记成失败**:只有真的出现失败调用、且指纹与记录一致时,G3 拒绝才写 failure,普通对话不再毒化证据链。
|
|
36
|
+
- **临时文件名跨进程冲突**:`store.json.<pid>.tmp`,两个 DSH 进程同时写不再互相覆盖。
|
|
37
|
+
- **`pitfalls` 别名**:删除 `if (existing.pitfalls)`(`[]` 为真)导致 pitfalls 指向 reverse_examples 的写法。
|
|
38
|
+
|
|
39
|
+
### 测试
|
|
40
|
+
- 新增 T87–T91、T63、T64;`node tools/selftest.mjs` 101 pass / 0 fail,`node tools/e2e.mjs` 25 pass / 0 fail。
|
|
41
|
+
|
|
42
|
+
### 仍未做
|
|
43
|
+
- 工具在写盘失败时仍回“成功”(`persist` 吞异常);语料每轮仍重建 3 次;历史记录未回填 `pitfalls` 别名。
|
|
44
|
+
|
|
4
45
|
## [1.2.0] — 2026-11-09 — 计划任务召回与已知问题清单
|
|
5
46
|
|
|
6
47
|
### 修复
|
package/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const PROBE_FILE = path.join(STORE_DIR, 'probe.json')
|
|
|
35
35
|
const THRESHOLD = 0.15
|
|
36
36
|
const MAX_EXPERIENCES = 2
|
|
37
37
|
const MAX_CHARS = 1600
|
|
38
|
-
const BUILD = '
|
|
38
|
+
const BUILD = 'r24-ux-fixes'
|
|
39
39
|
|
|
40
40
|
let CONFIG = { ...C.DEFAULT_CONFIG }
|
|
41
41
|
|
|
@@ -111,8 +111,21 @@ function tokens(text) {
|
|
|
111
111
|
return R.expand(text)
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Sessions own tool calls; an agent object can lose its id between the tool
|
|
116
|
+
* lifecycle and the turn lifecycle. Keying on the session keeps both sides in
|
|
117
|
+
* agreement — the mismatch was silently dropping every call of a turn.
|
|
118
|
+
*/
|
|
119
|
+
function keyOf(agent) {
|
|
120
|
+
if (!agent) return ''
|
|
121
|
+
return (agent.session && agent.session.id) || agent.id || ''
|
|
122
|
+
}
|
|
123
|
+
|
|
114
124
|
function scoreExperience(experience, queryTokens, index) {
|
|
115
|
-
|
|
125
|
+
const base = R.score(R.profileText(experience), queryTokens, index)
|
|
126
|
+
// A recipe learned on another machine, or never tagged at all, is weaker evidence.
|
|
127
|
+
const factor = C.platformFactor(experience)
|
|
128
|
+
return Math.round(base * factor * 1000) / 1000
|
|
116
129
|
}
|
|
117
130
|
|
|
118
131
|
// Retrieval floor. Configurable via "/memory config set recallThreshold 0.1"
|
|
@@ -239,6 +252,7 @@ export function apply(ctx) {
|
|
|
239
252
|
const traces = new Map()
|
|
240
253
|
const bound = new Map()
|
|
241
254
|
const liveCalls = new Map()
|
|
255
|
+
const resultsSeen = new Map()
|
|
242
256
|
const probe = { done: false }
|
|
243
257
|
let currentTrace = null
|
|
244
258
|
|
|
@@ -259,6 +273,8 @@ export function apply(ctx) {
|
|
|
259
273
|
lines.push('')
|
|
260
274
|
lines.push(`## ${experience.id} · ${experience.title}`)
|
|
261
275
|
if (experience.when) lines.push(`适用:${experience.when}`)
|
|
276
|
+
// Untagged records predate platform isolation; say so rather than pretend.
|
|
277
|
+
if (!C.platformOf(experience)) lines.push('平台:未标注(旧经验,按通用配方参考)')
|
|
262
278
|
const steps = (experience.steps || []).slice(0, 6)
|
|
263
279
|
steps.forEach((step, index) => lines.push(`${index + 1}. ${step}`))
|
|
264
280
|
const pitfalls = experience.pitfalls || experience.reverse_examples || []
|
|
@@ -291,7 +307,7 @@ export function apply(ctx) {
|
|
|
291
307
|
const queryTokens = tokens(queryText)
|
|
292
308
|
const index = R.corpus(store.experiences)
|
|
293
309
|
const scored = store.experiences
|
|
294
|
-
.map((experience) => ({ experience, score: scoreExperience(experience, queryTokens) }))
|
|
310
|
+
.map((experience) => ({ experience, score: scoreExperience(experience, queryTokens, index) }))
|
|
295
311
|
.sort((a, b) => b.score - a.score)
|
|
296
312
|
const candidates = scored.slice(0, 5).map(({ experience, score }) => ({
|
|
297
313
|
id: experience.id,
|
|
@@ -475,8 +491,9 @@ export function apply(ctx) {
|
|
|
475
491
|
function recordCall(exec, result) {
|
|
476
492
|
try {
|
|
477
493
|
ensureProbe(exec, result)
|
|
478
|
-
const id =
|
|
494
|
+
const id = keyOf(exec && exec.agent) || initiatorId()
|
|
479
495
|
if (!id) return
|
|
496
|
+
resultsSeen.set(id, (resultsSeen.get(id) || 0) + 1)
|
|
480
497
|
const ok = pickOk(exec, result)
|
|
481
498
|
const list = liveCalls.get(id) || []
|
|
482
499
|
if (list.length >= 80) list.shift()
|
|
@@ -522,15 +539,23 @@ export function apply(ctx) {
|
|
|
522
539
|
|
|
523
540
|
function noteGatedFailure(trace) {
|
|
524
541
|
try {
|
|
542
|
+
const calls = trace.calls || []
|
|
543
|
+
// Being refused for "nothing to capture" is not a failure of any recipe.
|
|
544
|
+
// A turn only voids a record's evidence chain when it actually hit an error
|
|
545
|
+
// while attempting that same job.
|
|
546
|
+
if (!calls.some((call) => call && call.ok === false)) return
|
|
547
|
+
const fingerprint = C.outcomeFingerprint(calls)
|
|
525
548
|
const queryTokens = tokens(trace.query || '')
|
|
526
549
|
const index = R.corpus(store.experiences)
|
|
527
550
|
let best = null
|
|
528
551
|
for (const existing of store.experiences) {
|
|
529
552
|
if (!['verified', 'auto-verified', 'candidate'].includes(existing.status)) continue
|
|
530
553
|
const score = scoreExperience(existing, queryTokens, index)
|
|
531
|
-
if (score
|
|
554
|
+
if (score < CONFIG.mergeThreshold) continue
|
|
555
|
+
if (!C.fingerprintsMatch(existing, { fingerprint })) continue
|
|
556
|
+
if (!best || score > best.score) best = { existing, score }
|
|
532
557
|
}
|
|
533
|
-
if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(
|
|
558
|
+
if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(calls))
|
|
534
559
|
} catch (error) {
|
|
535
560
|
console.warn('[experience] failure evidence failed:', error && error.message)
|
|
536
561
|
}
|
|
@@ -724,15 +749,31 @@ export function apply(ctx) {
|
|
|
724
749
|
|
|
725
750
|
ctx.on('agent/turn-stopping', (payload) => {
|
|
726
751
|
try {
|
|
727
|
-
const id =
|
|
728
|
-
|
|
752
|
+
const id = keyOf(payload && payload.agent)
|
|
753
|
+
let calls = liveCalls.get(id) || []
|
|
729
754
|
liveCalls.delete(id)
|
|
755
|
+
let keyFallback = false
|
|
756
|
+
if (!calls.length && liveCalls.size === 1) {
|
|
757
|
+
const [otherId, otherCalls] = liveCalls.entries().next().value
|
|
758
|
+
if (otherCalls && otherCalls.length) {
|
|
759
|
+
calls = otherCalls
|
|
760
|
+
liveCalls.delete(otherId)
|
|
761
|
+
keyFallback = true
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const seen = resultsSeen.get(id) || 0
|
|
765
|
+
resultsSeen.delete(id)
|
|
730
766
|
const trace = traces.get(id)
|
|
731
767
|
if (!trace || trace.turn !== payload.turn) return
|
|
732
768
|
trace.calls = calls.slice(-CONFIG.maxCalls)
|
|
769
|
+
trace.resultsSeen = seen
|
|
770
|
+
if (keyFallback) trace.keyFallback = true
|
|
733
771
|
trace.endedAt = new Date().toISOString()
|
|
734
772
|
trace.durationMs = Date.parse(trace.endedAt) - Date.parse(trace.startedAt)
|
|
735
773
|
const capture = runCapture(trace, payload.agent)
|
|
774
|
+
if (capture && !capture.ok && /^G1/.test(String(capture.reason)) && seen > trace.calls.length) {
|
|
775
|
+
capture.diagnostic = `采集异常:本轮收到 ${seen} 次工具结果,只记录 ${trace.calls.length} 条${keyFallback ? '(已用 keyFallback 兜底)' : ''}`
|
|
776
|
+
}
|
|
736
777
|
// Raw hints are needed while capturing (scope + fingerprint); they must not
|
|
737
778
|
// reach the durable trace, so collapse them to the redacted form now.
|
|
738
779
|
trace.calls = (trace.calls || []).map((call) => ({ ...call, hint: C.clip(call.hint, 120) }))
|
|
@@ -974,6 +1015,17 @@ export function apply(ctx) {
|
|
|
974
1015
|
}),
|
|
975
1016
|
].join('\n')
|
|
976
1017
|
}
|
|
1018
|
+
if (line === 'discard' || line === '--discard' || line === 'reject-all') {
|
|
1019
|
+
const candidates = C.reviewTargets(store.experiences)
|
|
1020
|
+
if (!candidates.length) return '没有待确认的候选经验。'
|
|
1021
|
+
const at = new Date().toISOString()
|
|
1022
|
+
for (const item of candidates) {
|
|
1023
|
+
item.status = 'rejected'
|
|
1024
|
+
item.verify = { by: 'human', at, method: 'command-reject-all' }
|
|
1025
|
+
}
|
|
1026
|
+
persist()
|
|
1027
|
+
return `已拒绝 ${candidates.length} 条候选:${candidates.map((item) => item.id).join(', ')}`
|
|
1028
|
+
}
|
|
977
1029
|
if (line === '--approve-all' || line === 'approve-all' || line === 'approve --all') {
|
|
978
1030
|
const candidates = C.reviewTargets(store.experiences)
|
|
979
1031
|
if (!candidates.length) return '没有待确认的候选经验。'
|
|
@@ -1103,7 +1155,10 @@ export function apply(ctx) {
|
|
|
1103
1155
|
const ageIndex = parts.indexOf('--age')
|
|
1104
1156
|
const simulatedDays = ageIndex >= 0 ? Number(parts[ageIndex + 1]) : 0
|
|
1105
1157
|
const simulated = Number.isFinite(simulatedDays) && simulatedDays > 0
|
|
1106
|
-
|
|
1158
|
+
if (simulated && parts.includes('--apply')) {
|
|
1159
|
+
return err('--age 只用于模拟展示,不能和 --apply 一起用:先用 --age 看水位,再不带 --age 执行 --apply')
|
|
1160
|
+
}
|
|
1161
|
+
const now = Date.now()
|
|
1107
1162
|
if (!parts.includes('--apply')) {
|
|
1108
1163
|
D.markDecay(store.experiences, Date.now(), CONFIG)
|
|
1109
1164
|
persist()
|
package/lib/capture.js
CHANGED
|
@@ -120,6 +120,18 @@ export function classify(call) {
|
|
|
120
120
|
return { kind, target, name }
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* "cd somewhere; node test.js" says nothing about the recipe beyond the command
|
|
125
|
+
* that actually ran, so keep only the trailing command.
|
|
126
|
+
*/
|
|
127
|
+
export function stripCdPrefix(command) {
|
|
128
|
+
const text = String(command || '').trim()
|
|
129
|
+
const parts = text.split(/\s*(?:;|&&|\|\|)\s*/).filter(Boolean)
|
|
130
|
+
if (parts.length <= 1) return text.replace(/;+$/, '')
|
|
131
|
+
const kept = parts.filter((part) => !/^cd\s/i.test(part) && !/^Set-Location\b/i.test(part))
|
|
132
|
+
return (kept.length ? kept.join('; ') : parts[parts.length - 1]).replace(/;+$/, '')
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
export function stepLabel(call) {
|
|
124
136
|
const { kind, target, name } = classify(call)
|
|
125
137
|
switch (kind) {
|
|
@@ -128,7 +140,7 @@ export function stepLabel(call) {
|
|
|
128
140
|
case 'read':
|
|
129
141
|
return target ? `读 ${target.split('/').pop()}` : '读文件'
|
|
130
142
|
case 'run':
|
|
131
|
-
return target ? `跑 ${target}` : '执行命令'
|
|
143
|
+
return target ? `跑 ${stripCdPrefix(target)}` : '执行命令'
|
|
132
144
|
case 'search':
|
|
133
145
|
return target ? `检索 ${target}` : '检索'
|
|
134
146
|
default:
|
|
@@ -172,7 +184,7 @@ export function stepPriority(call) {
|
|
|
172
184
|
* output) is transcript noise: it describes how the agent measured the world,
|
|
173
185
|
* not what a second run should do.
|
|
174
186
|
*/
|
|
175
|
-
const NOISE_PREFIX = /^(\$|New-Item|New-ItemProperty|New-Object|Test-Path|Get-ChildItem|Get-Item|Set-Location|Push-Location|Pop-Location|Write-Host|Write-Output|Copy-Item|Remove-Item|Move-Item|Rename-Item)\b/
|
|
187
|
+
const NOISE_PREFIX = /^(\$|New-Item|New-ItemProperty|New-Object|Test-Path|Get-ChildItem|Get-Item|Set-Location|Push-Location|Pop-Location|Write-Host|Write-Output|Write-Verbose|Clear-Host|Copy-Item|Remove-Item|Move-Item|Rename-Item|Start-Sleep|sleep|cd)\b/
|
|
176
188
|
const ASSIGNMENT = /^[A-Za-z_$][\w$]*\s*=[^=]/
|
|
177
189
|
|
|
178
190
|
export function isNoiseStep(label) {
|
|
@@ -541,6 +553,10 @@ const GENERIC_DIRS = new Set([
|
|
|
541
553
|
// say nothing about *which project* a record belongs to.
|
|
542
554
|
'node', 'python', 'python3', 'npm', 'pnpm', 'npx', 'yarn', 'git', 'pwsh',
|
|
543
555
|
'deno', 'bun', 'runtime', 'runtimes', 'dependencies', 'dsh', 'windows',
|
|
556
|
+
// Home-directory placeholders: a user name is not a project name.
|
|
557
|
+
'dev', 'developer', 'user', 'userprofile',
|
|
558
|
+
// Sandbox and CI roots: fixtures live here, they are not project names.
|
|
559
|
+
'work', 'projects', 'repos',
|
|
544
560
|
])
|
|
545
561
|
|
|
546
562
|
/**
|
|
@@ -636,22 +652,46 @@ export function buildCandidate(trace, info, config) {
|
|
|
636
652
|
const named = writes
|
|
637
653
|
.map((call) => artifactName(call))
|
|
638
654
|
.find((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) && /[A-Za-z0-9]{2,}/.test(name))
|
|
655
|
+
// Scope is computed before rendering so the title and the trigger can name the
|
|
656
|
+
// project. "新增 index" is unreadable in a review list; "新增 index(dsh-experience)"
|
|
657
|
+
// tells the human which project the recipe belongs to.
|
|
658
|
+
const scope = mentionedDirs(
|
|
659
|
+
[
|
|
660
|
+
...(calls || []).map((call) => firstString(call && (call.hint || call.argsHint || call.args))),
|
|
661
|
+
trace && trace.query,
|
|
662
|
+
].join(' '),
|
|
663
|
+
)
|
|
664
|
+
const project = projectScope({ scope })[0] || ''
|
|
665
|
+
const suffix = project ? `(${project})` : ''
|
|
639
666
|
const title = clip(
|
|
640
667
|
runOnly
|
|
641
668
|
? scriptName
|
|
642
|
-
? `跑 ${scriptName}`
|
|
669
|
+
? `跑 ${scriptName}${suffix}`
|
|
643
670
|
: primary
|
|
644
671
|
? `跑 ${primary}`
|
|
645
672
|
: verbsOf(taskType)
|
|
646
673
|
: named
|
|
647
|
-
? `新增 ${named}`
|
|
674
|
+
? `新增 ${named}${suffix}`
|
|
648
675
|
: primary
|
|
649
676
|
? `${verb} ${primary}`
|
|
650
677
|
: verbsOf(taskType),
|
|
651
678
|
110,
|
|
652
679
|
)
|
|
653
680
|
const place = workspaceName(info && info.cwd)
|
|
654
|
-
const
|
|
681
|
+
const testRun = runs.find((call) => TEST_COMMAND.test(firstString(call && (call.hint || call.argsHint || call.args))))
|
|
682
|
+
const testCommand = testRun ? classify(testRun).target : ''
|
|
683
|
+
// The trigger line must discriminate between recipes, not repeat the task type
|
|
684
|
+
// ("在 desktop 里 新增函数/模块并配用例" fits every single candidate).
|
|
685
|
+
const when = clip(
|
|
686
|
+
project
|
|
687
|
+
? `在 ${project} 里 ${verbsOf(taskType)}${testCommand ? `,验证 ${testCommand}` : ''}`
|
|
688
|
+
: testCommand
|
|
689
|
+
? `当需要${verbsOf(taskType)}时,验证 ${testCommand}`
|
|
690
|
+
: place
|
|
691
|
+
? `在 ${place} 里 ${verbsOf(taskType)}`
|
|
692
|
+
: `当需要${verbsOf(taskType)}时`,
|
|
693
|
+
160,
|
|
694
|
+
)
|
|
655
695
|
const keywords = [...new Set([taskType, ...steps.map((step) => step.split(' ')[1] || '').filter(Boolean)])]
|
|
656
696
|
.filter(Boolean)
|
|
657
697
|
.slice(0, 12)
|
|
@@ -660,13 +700,11 @@ export function buildCandidate(trace, info, config) {
|
|
|
660
700
|
const record = {
|
|
661
701
|
status: 'candidate',
|
|
662
702
|
platform: process.platform,
|
|
703
|
+
// Task shape as recorded, not as re-guessed from prose later: a write task and
|
|
704
|
+
// a "run this script" task must never merge even when they share every noun.
|
|
705
|
+
intent: runOnly ? 'run' : 'write',
|
|
663
706
|
// Scope detection needs the raw paths; every rendered field below is normalized.
|
|
664
|
-
scope
|
|
665
|
-
[
|
|
666
|
-
...(calls || []).map((call) => firstString(call && (call.hint || call.argsHint || call.args))),
|
|
667
|
-
trace && trace.query,
|
|
668
|
-
].join(' '),
|
|
669
|
-
),
|
|
707
|
+
scope,
|
|
670
708
|
fingerprint: outcomeFingerprint(calls),
|
|
671
709
|
title,
|
|
672
710
|
taskType,
|
|
@@ -703,6 +741,16 @@ export function platformOf(record) {
|
|
|
703
741
|
* darwin. Unknown platform never vetoes: records written before v1.1 carry no
|
|
704
742
|
* tag, and every one of them was learned on this machine.
|
|
705
743
|
*/
|
|
744
|
+
/**
|
|
745
|
+
* Old records carry no platform tag: they were learned before isolation existed.
|
|
746
|
+
* They stay usable, but must not outrank a record that was proven on this machine.
|
|
747
|
+
*/
|
|
748
|
+
export function platformFactor(record) {
|
|
749
|
+
const tag = platformOf(record)
|
|
750
|
+
if (!tag) return 0.85
|
|
751
|
+
return tag === process.platform ? 1 : 0
|
|
752
|
+
}
|
|
753
|
+
|
|
706
754
|
export function platformConflicts(record) {
|
|
707
755
|
const tag = platformOf(record)
|
|
708
756
|
return Boolean(tag && tag !== process.platform)
|
|
@@ -723,7 +771,6 @@ export function mergeInto(existing, draft) {
|
|
|
723
771
|
}
|
|
724
772
|
}
|
|
725
773
|
existing.reverse_examples = [...canonical, ...added].slice(0, 10)
|
|
726
|
-
if (existing.pitfalls) existing.pitfalls = existing.reverse_examples
|
|
727
774
|
|
|
728
775
|
const stepSeen = new Set(existing.steps || [])
|
|
729
776
|
const newSteps = []
|
package/lib/decay.js
CHANGED
|
@@ -127,7 +127,16 @@ export function selectEvictions(records, now = Date.now(), config = {}) {
|
|
|
127
127
|
return { ...base, triggered: false, evictions: [], reason: `未达容量阈值 ${threshold}(当前 ${list.length})` }
|
|
128
128
|
}
|
|
129
129
|
const eligible = list
|
|
130
|
-
.filter(
|
|
130
|
+
.filter(
|
|
131
|
+
(record) =>
|
|
132
|
+
record &&
|
|
133
|
+
record.status !== 'verified' &&
|
|
134
|
+
record.status !== 'rejected' &&
|
|
135
|
+
// Superseded records are the anti-re-adoption guard: archiving one lets the
|
|
136
|
+
// refused fingerprint be captured and recommended again.
|
|
137
|
+
record.status !== 'superseded' &&
|
|
138
|
+
!isPinned(record),
|
|
139
|
+
)
|
|
131
140
|
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
132
141
|
.filter((entry) => entry.info.dormant)
|
|
133
142
|
.sort((left, right) => left.info.rank - right.info.rank)
|
|
@@ -200,17 +209,22 @@ export function renderDecayReport(store, now = Date.now(), config = {}) {
|
|
|
200
209
|
`[M3.3 衰减报告] 库 ${live.length} 条 · active ${counts.active} / dormant ${counts.dormant} / pinned ${counts.pinned}`,
|
|
201
210
|
...(hiddenRejected ? [`已排除 ${hiddenRejected} 条 rejected(审计留档,不参与排序与容量;\/memory list --all 查看)`] : []),
|
|
202
211
|
`容量: ${proposal.count}/${proposal.libraryLimit}(阈值 ${proposal.threshold} · 比例 ${num(config.capacityRatio, DECAY_DEFAULTS.capacityRatio)})· ${proposal.triggered ? '已达阈值' : '未达阈值'}`,
|
|
203
|
-
proposal.triggered
|
|
204
|
-
?
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
...(proposal.triggered
|
|
213
|
+
? [
|
|
214
|
+
`建议归档 ${proposal.evictions.length} 条(最多 ${num(config.evictBatch, DECAY_DEFAULTS.evictBatch)} 条/次,按 rank 升序):`,
|
|
215
|
+
...(top.length
|
|
216
|
+
? top.map((entry, index) => ` ${index + 1}. ${decayLine(entry.record, now, config)}${eligible.has(entry.record.id) ? ' ← 可归档' : ''}`)
|
|
217
|
+
: [' (库为空)']),
|
|
218
|
+
]
|
|
219
|
+
: [`容量充足(${proposal.count}/${proposal.libraryLimit}),暂时不需要淘汰任何记录。`]),
|
|
209
220
|
'只列出建议,不删除任何记录。确认后运行 /memory decay --apply(移入 archive,可用 /memory restore <id> 恢复)。',
|
|
210
221
|
]
|
|
211
222
|
return lines.join('\n')
|
|
212
223
|
}
|
|
213
224
|
|
|
225
|
+
export const ARCHIVE_LIMIT = 200
|
|
226
|
+
export const EVICTION_LIMIT = 500
|
|
227
|
+
|
|
214
228
|
export function applyEvictions(store, now = Date.now(), config = {}, by = 'command') {
|
|
215
229
|
const records = Array.isArray(store && store.experiences) ? store.experiences : []
|
|
216
230
|
const proposal = selectEvictions(records, now, config)
|
|
@@ -240,6 +254,10 @@ export function applyEvictions(store, now = Date.now(), config = {}, by = 'comma
|
|
|
240
254
|
ageDays: proposalItem ? proposalItem.ageDays : null,
|
|
241
255
|
})
|
|
242
256
|
}
|
|
257
|
+
// Archived records keep their whole body, so the list has to be bounded or the
|
|
258
|
+
// store grows forever on a long-lived machine.
|
|
259
|
+
if (store.archive.length > ARCHIVE_LIMIT) store.archive = store.archive.slice(-ARCHIVE_LIMIT)
|
|
260
|
+
if (store.evictions.length > EVICTION_LIMIT) store.evictions = store.evictions.slice(-EVICTION_LIMIT)
|
|
243
261
|
return { archived: archived.map((record) => record.id), triggered: true, at, reason: `已归档 ${archived.length} 条(未物理删除,可用 /memory restore <id> 恢复)` }
|
|
244
262
|
}
|
|
245
263
|
|
|
@@ -251,6 +269,9 @@ export function restoreRecord(store, id) {
|
|
|
251
269
|
delete record.archivedAt
|
|
252
270
|
delete record.archivedBy
|
|
253
271
|
delete record.decay
|
|
272
|
+
// Resetting the snapshot alone would decay again on the next pass: age is
|
|
273
|
+
// measured from the last use, so a restore has to count as a use.
|
|
274
|
+
record.lastUsedAt = new Date(Date.now()).toISOString()
|
|
254
275
|
store.experiences.push(record)
|
|
255
276
|
store.evictions = Array.isArray(store.evictions) ? store.evictions : []
|
|
256
277
|
store.evictions.push({ id, at: new Date().toISOString(), by: 'restore', reason: '人工恢复' })
|
package/lib/recall.js
CHANGED
|
@@ -138,6 +138,11 @@ const round = (value) => Math.round(value * 1000) / 1000
|
|
|
138
138
|
* Empty means "cannot tell" and never vetoes anything.
|
|
139
139
|
*/
|
|
140
140
|
export function intentOf(experienceOrText) {
|
|
141
|
+
// Records written by buildCandidate carry the intent they were captured with.
|
|
142
|
+
// Re-deriving it from prose is only the fallback for hand-written records.
|
|
143
|
+
if (experienceOrText && typeof experienceOrText === 'object' && (experienceOrText.intent === 'run' || experienceOrText.intent === 'write')) {
|
|
144
|
+
return experienceOrText.intent
|
|
145
|
+
}
|
|
141
146
|
const text =
|
|
142
147
|
typeof experienceOrText === 'string' ? experienceOrText : profileText(experienceOrText)
|
|
143
148
|
const found = features(text)
|
|
@@ -207,6 +212,11 @@ const scriptWeight = (token) => (/^[\u4e00-\u9fff]+$/.test(token) ? 0.45 : 1)
|
|
|
207
212
|
* Both terms are bounded, so the result is always within [0, 1].
|
|
208
213
|
*/
|
|
209
214
|
export function score(profile, queryTokens, c) {
|
|
215
|
+
// Passing a record here used to score 0 silently, which looks exactly like
|
|
216
|
+
// "this experience is irrelevant" from the outside. Fail loudly instead.
|
|
217
|
+
if (typeof profile !== 'string') {
|
|
218
|
+
throw new TypeError('score() expects plain text; call profileText(record) first')
|
|
219
|
+
}
|
|
210
220
|
const expTokens = expand(profile)
|
|
211
221
|
if (!queryTokens || !queryTokens.size || !expTokens.size) return 0
|
|
212
222
|
let weightSum = 0
|
package/lib/store.js
CHANGED
|
@@ -1,15 +1,47 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// them out of the file, silently losing archived records.
|
|
1
|
+
// A turn persists the store several times (injection bookkeeping, capture, decay,
|
|
2
|
+
// trace close). Each write serialises the whole file, and by now the file carries
|
|
3
|
+
// a hundred turns, so identical rewrites are pure waste. Two cheap guards:
|
|
4
|
+
// 1. skip the write when the serialised payload equals the last one written;
|
|
5
|
+
// 2. keep a counter so the tests can prove it, instead of trusting mtime.
|
|
7
6
|
|
|
8
7
|
import fs from 'node:fs'
|
|
9
8
|
|
|
9
|
+
const lastWritten = new Map()
|
|
10
|
+
const stats = { writes: 0, skipped: 0 }
|
|
11
|
+
|
|
12
|
+
export function writeStore(file, store) {
|
|
13
|
+
const text = JSON.stringify(store, null, 2)
|
|
14
|
+
// Same bytes as the last successful write to this path means nothing changed;
|
|
15
|
+
// several listeners can persist the same snapshot within one turn.
|
|
16
|
+
if (lastWritten.get(file) === text) {
|
|
17
|
+
stats.skipped += 1
|
|
18
|
+
return { written: false }
|
|
19
|
+
}
|
|
20
|
+
const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
|
|
21
|
+
if (dir) fs.mkdirSync(dir, { recursive: true })
|
|
22
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
23
|
+
fs.writeFileSync(tmp, text, 'utf8')
|
|
24
|
+
fs.renameSync(tmp, file)
|
|
25
|
+
lastWritten.set(file, text)
|
|
26
|
+
stats.writes += 1
|
|
27
|
+
return { written: true }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Exposed for tests; also handy when diagnosing write amplification. */
|
|
31
|
+
export function writeStats() {
|
|
32
|
+
return { ...stats }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Tests need a clean slate between fixtures. */
|
|
36
|
+
export function resetWriteCache() {
|
|
37
|
+
lastWritten.clear()
|
|
38
|
+
stats.writes = 0
|
|
39
|
+
stats.skipped = 0
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
export function emptyStore() {
|
|
11
43
|
return {
|
|
12
|
-
version:
|
|
44
|
+
version: 2,
|
|
13
45
|
nextId: 1,
|
|
14
46
|
nextInjectionId: 1,
|
|
15
47
|
experiences: [],
|
|
@@ -27,11 +59,18 @@ const array = (value) => (Array.isArray(value) ? value : [])
|
|
|
27
59
|
* Unknown extra fields are preserved verbatim on purpose: a plugin upgrade that
|
|
28
60
|
* adds a table must not lose it when an older build reloads the file.
|
|
29
61
|
*/
|
|
62
|
+
const highestId = (list) =>
|
|
63
|
+
list.reduce((max, record) => {
|
|
64
|
+
const match = record && typeof record.id === 'string' ? /^exp-(\d+)$/.exec(record.id) : null;
|
|
65
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
66
|
+
}, 0);
|
|
67
|
+
|
|
30
68
|
export function normalizeStore(parsed) {
|
|
31
69
|
if (!parsed || typeof parsed !== 'object') throw new Error('not an object')
|
|
32
70
|
const known = {
|
|
33
71
|
version: Math.max(2, Number(parsed.version || 1)),
|
|
34
|
-
|
|
72
|
+
// A hand-edited or truncated store must never hand out an id that already exists.
|
|
73
|
+
nextId: Math.max(Number(parsed.nextId) || 1, highestId([...array(parsed.experiences), ...array(parsed.archive)]) + 1),
|
|
35
74
|
nextInjectionId: Number(parsed.nextInjectionId) || 1,
|
|
36
75
|
experiences: array(parsed.experiences),
|
|
37
76
|
injections: array(parsed.injections),
|
|
@@ -49,18 +88,12 @@ export function normalizeStore(parsed) {
|
|
|
49
88
|
export function readStore(file) {
|
|
50
89
|
try {
|
|
51
90
|
const raw = fs.readFileSync(file, 'utf8')
|
|
52
|
-
|
|
91
|
+
const store = normalizeStore(JSON.parse(raw))
|
|
92
|
+
// Seed the cache so a read followed by an unchanged write is skipped too.
|
|
93
|
+
lastWritten.set(file, JSON.stringify(store, null, 2))
|
|
94
|
+
return { store }
|
|
53
95
|
} catch (error) {
|
|
54
96
|
if (error && error.code === 'ENOENT') return { store: emptyStore(), missing: true }
|
|
55
97
|
return { store: emptyStore(), error }
|
|
56
98
|
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Atomic-ish write: temp file + rename, UTF-8, no BOM. */
|
|
60
|
-
export function writeStore(file, store) {
|
|
61
|
-
const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
|
|
62
|
-
if (dir) fs.mkdirSync(dir, { recursive: true })
|
|
63
|
-
const tmp = `${file}.tmp`
|
|
64
|
-
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8')
|
|
65
|
-
fs.renameSync(tmp, file)
|
|
66
99
|
}
|
package/package.json
CHANGED
package/tools/e2e.mjs
CHANGED
|
@@ -410,6 +410,23 @@ await test('T62 the same recipe in a second project stays separate', async () =>
|
|
|
410
410
|
assert.match(String(second.reason), /scope 不同|意图不同|按新任务/, `reason: ${second.reason}`)
|
|
411
411
|
})
|
|
412
412
|
|
|
413
|
+
await test('T63 --age is a simulation and refuses to combine with --apply', async () => {
|
|
414
|
+
const out = await cmd('decay --age 400 --apply');
|
|
415
|
+
assert.equal(out.kind, 'error', out.text);
|
|
416
|
+
assert.match(out.text, /不能和 --apply 一起用/);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await test('T64 a refused turn without errors does not void evidence', async () => {
|
|
420
|
+
const before = JSON.stringify(readStore().experiences.map((e) => e.proof || null));
|
|
421
|
+
await enter(A, 40, 1, '在 acme-app 里新增 pad 函数并配 node:test 用例');
|
|
422
|
+
await call(A, 'read', { file_path: 'D:\\work\\acme-app\\src\\pad.js' });
|
|
423
|
+
await call(A, 'read', { file_path: 'D:\\work\\acme-app\\test\\pad.test.mjs' });
|
|
424
|
+
await fire('agent/turn-stopping', { agent: A, turn: 40, signal: undefined });
|
|
425
|
+
const capture = readStore().turns.at(-1).capture;
|
|
426
|
+
assert.equal(capture.ok, false, JSON.stringify(capture));
|
|
427
|
+
assert.equal(JSON.stringify(readStore().experiences.map((e) => e.proof || null)), before, 'no failure evidence');
|
|
428
|
+
});
|
|
429
|
+
|
|
413
430
|
console.log('');
|
|
414
431
|
let pass = 0;
|
|
415
432
|
results.forEach((item, index) => {
|
package/tools/selftest.mjs
CHANGED
|
@@ -341,7 +341,7 @@ test('buildCandidate: shape, taskType, steps, evidence', () => {
|
|
|
341
341
|
assert.equal(draft.source.auto, true)
|
|
342
342
|
assert.equal(draft.verify, null)
|
|
343
343
|
assert.equal(draft.occurrences, 1)
|
|
344
|
-
assert.equal(draft.title, '新增 truncate')
|
|
344
|
+
assert.equal(draft.title, '新增 truncate(acme-app)')
|
|
345
345
|
assert.ok(draft.when.includes('acme-app'), draft.when)
|
|
346
346
|
assert.deepEqual(draft.steps, ['写 src/<name>.js', '跑 node test/<name>.mjs'])
|
|
347
347
|
assert.equal(draft.evidence, 'session-x:turn-3 · calls=3 errors=0')
|
|
@@ -710,7 +710,9 @@ test('T30: decayLine and decay report expose rank/age and stay proposal-only', (
|
|
|
710
710
|
assert.ok(report.includes('只列出建议,不删除任何记录'), report)
|
|
711
711
|
assert.ok(report.includes('/memory decay --apply'), report)
|
|
712
712
|
assert.ok(report.includes('/memory restore <id>'), report)
|
|
713
|
-
|
|
713
|
+
// Under capacity there is nothing to rank: no observation list at all.
|
|
714
|
+
assert.ok(report.includes('容量充足'), report)
|
|
715
|
+
assert.equal(report.split('\n').filter((row) => /^ {2}\d+\. /.test(row)).length, 0, 'no observation list under capacity')
|
|
714
716
|
const many = [...records, ...Array.from({ length: 160 }, (_, i) => ({ id: `f-${i}`, status: 'candidate', lastUsedAt: ago(1) }))]
|
|
715
717
|
const hot = D.renderDecayReport({ experiences: many }, NOW)
|
|
716
718
|
assert.ok(hot.includes('已达阈值'), hot)
|
|
@@ -814,7 +816,7 @@ test('T34: a real recipe still passes G7 and gets an actionable title', () => {
|
|
|
814
816
|
const gate = C.gateStepQuality(trace, draft, C.DEFAULT_CONFIG)
|
|
815
817
|
assert.equal(gate.ok, true, gate.reason)
|
|
816
818
|
assert.equal(gate.effective, 2)
|
|
817
|
-
assert.equal(draft.title, '新增 truncate')
|
|
819
|
+
assert.equal(draft.title, '新增 truncate(acme-app)')
|
|
818
820
|
assert.equal(C.actionableTitle(draft.title), true)
|
|
819
821
|
})
|
|
820
822
|
|
|
@@ -1135,7 +1137,7 @@ test('T78 a pure run track is captured, titled and fingerprinted by its command'
|
|
|
1135
1137
|
assert.equal(quality.ok, true, quality.reason)
|
|
1136
1138
|
|
|
1137
1139
|
const draft = C.buildCandidate(trace, { runOnly: true }, C.DEFAULT_CONFIG)
|
|
1138
|
-
assert.equal(draft.title, '跑 run')
|
|
1140
|
+
assert.equal(draft.title, '跑 run(cross-a)')
|
|
1139
1141
|
assert.deepEqual(draft.fingerprint.commands, ['node'])
|
|
1140
1142
|
assert.deepEqual(C.commandHeads(calls), ['node'])
|
|
1141
1143
|
})
|
|
@@ -1208,6 +1210,120 @@ test('T84 a scheduled wrapper yields the task, not the wrapper', () => {
|
|
|
1208
1210
|
assert.equal(R.stripReminder('普通任务'), '普通任务')
|
|
1209
1211
|
})
|
|
1210
1212
|
|
|
1213
|
+
test('T85 an unchanged store is not rewritten', () => {
|
|
1214
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-store-'))
|
|
1215
|
+
const file = path.join(dir, 'store.json')
|
|
1216
|
+
S.resetWriteCache()
|
|
1217
|
+
const store = S.emptyStore()
|
|
1218
|
+
assert.equal(S.writeStore(file, store).written, true)
|
|
1219
|
+
assert.equal(S.writeStore(file, store).written, false)
|
|
1220
|
+
store.nextId = 7
|
|
1221
|
+
assert.equal(S.writeStore(file, store).written, true)
|
|
1222
|
+
const stats = S.writeStats()
|
|
1223
|
+
assert.equal(stats.writes, 2)
|
|
1224
|
+
assert.equal(stats.skipped, 1)
|
|
1225
|
+
// A read seeds the cache, so "load then persist an untouched snapshot" is free.
|
|
1226
|
+
S.resetWriteCache()
|
|
1227
|
+
const reread = S.readStore(file)
|
|
1228
|
+
assert.equal(S.writeStore(file, reread.store).written, false)
|
|
1229
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1232
|
+
test('T86 the archive keeps the newest 200 records', () => {
|
|
1233
|
+
assert.equal(D.ARCHIVE_LIMIT, 200)
|
|
1234
|
+
const store = { experiences: [], archive: [], evictions: [], nextId: 1 }
|
|
1235
|
+
for (let i = 0; i < 205; i += 1) {
|
|
1236
|
+
store.experiences.push({ id: 'exp-' + i, status: 'candidate', createdAt: '2020-01-01T00:00:00.000Z', steps: ['a', 'b'] })
|
|
1237
|
+
}
|
|
1238
|
+
const result = D.applyEvictions(store, Date.now(), { ...C.DEFAULT_CONFIG, libraryLimit: 10 }, 'test')
|
|
1239
|
+
assert.ok(result.archived.length > 0)
|
|
1240
|
+
assert.ok(store.archive.length <= D.ARCHIVE_LIMIT)
|
|
1241
|
+
assert.equal(store.archive.at(-1).archivedBy, 'test')
|
|
1242
|
+
assert.ok(store.evictions.length <= D.EVICTION_LIMIT)
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
test('T87 recall scores with the IDF corpus', () => {
|
|
1246
|
+
const source = fs.readFileSync(new URL('../index.js', import.meta.url), 'utf8')
|
|
1247
|
+
assert.ok(source.includes('scoreExperience(experience, queryTokens, index)'), 'recall passes the corpus')
|
|
1248
|
+
assert.ok(!source.includes('scoreExperience(experience, queryTokens)'), 'no call site drops the corpus')
|
|
1249
|
+
})
|
|
1250
|
+
|
|
1251
|
+
test('T88 a stale nextId is reconciled with existing ids', () => {
|
|
1252
|
+
const store = S.normalizeStore({
|
|
1253
|
+
nextId: 3,
|
|
1254
|
+
experiences: [{ id: 'exp-1' }, { id: 'exp-9' }],
|
|
1255
|
+
archive: [{ id: 'exp-12' }],
|
|
1256
|
+
})
|
|
1257
|
+
assert.equal(store.nextId, 13)
|
|
1258
|
+
const empty = S.normalizeStore({ experiences: [] })
|
|
1259
|
+
assert.equal(empty.nextId, 1)
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
test('T89 superseded records are never evicted', () => {
|
|
1263
|
+
const now = Date.parse('2026-11-09T00:00:00Z')
|
|
1264
|
+
const old = new Date(now - 400 * 86400000).toISOString()
|
|
1265
|
+
const records = []
|
|
1266
|
+
for (let i = 0; i < 159; i += 1) {
|
|
1267
|
+
records.push({ id: 'exp-' + (100 + i), status: 'candidate', createdAt: old })
|
|
1268
|
+
}
|
|
1269
|
+
records.push({ id: 'exp-super', status: 'superseded', createdAt: old })
|
|
1270
|
+
const result = D.selectEvictions(records, now, {})
|
|
1271
|
+
assert.equal(result.triggered, true)
|
|
1272
|
+
assert.ok(result.evictions.length > 0, 'old candidates are proposed')
|
|
1273
|
+
assert.ok(!result.evictions.some((item) => item.id === 'exp-super'), 'the G8 guard stays in the library')
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
test('T90 restore makes a record young again', () => {
|
|
1277
|
+
const now = Date.parse('2026-11-09T00:00:00Z')
|
|
1278
|
+
const old = new Date(now - 400 * 86400000).toISOString()
|
|
1279
|
+
const store = { experiences: [], archive: [{ id: 'exp-old', status: 'candidate', createdAt: old, steps: [] }], evictions: [] }
|
|
1280
|
+
const restored = D.restoreRecord(store, 'exp-old')
|
|
1281
|
+
assert.ok(restored, 'record comes back')
|
|
1282
|
+
D.markDecay(store.experiences, now, {})
|
|
1283
|
+
const decay = store.experiences[0].decay
|
|
1284
|
+
assert.equal(decay.state, 'active')
|
|
1285
|
+
assert.equal(decay.ageDays, 0)
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
test('T91 a fresh store starts at schema version 2', () => {
|
|
1289
|
+
assert.equal(S.emptyStore().version, 2)
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
// ---------------------------------------------------------------- v1.2.2
|
|
1293
|
+
|
|
1294
|
+
test('score: a non-string profile fails loudly instead of scoring 0', () => {
|
|
1295
|
+
assert.throws(() => R.score({ title: 'x' }, R.expand('新增函数'), R.corpus([])), /profileText/)
|
|
1296
|
+
assert.equal(typeof R.score('新增 pad 函数', R.expand('新增 pad 函数'), R.corpus([])), 'number')
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
test('platformFactor: untagged records are discounted, foreign ones excluded', () => {
|
|
1300
|
+
assert.equal(C.platformFactor({}), 0.85)
|
|
1301
|
+
assert.equal(C.platformFactor({ platform: process.platform }), 1)
|
|
1302
|
+
assert.equal(C.platformFactor({ platform: process.platform === 'win32' ? 'linux' : 'win32' }), 0)
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
test('stepLabel: hangs off the command, not the scaffolding in front of it', () => {
|
|
1306
|
+
const label = C.stepLabel({ name: 'pwsh', hint: 'cd D:\\work\\acme-app; node test\\pad.test.mjs', ok: true })
|
|
1307
|
+
assert.ok(!label.includes('cd '), label)
|
|
1308
|
+
assert.ok(label.includes('node'), label)
|
|
1309
|
+
assert.ok(C.isNoiseStep('cd D:\\work'), 'a bare cd is not a step')
|
|
1310
|
+
assert.ok(C.isNoiseStep('Start-Sleep -Seconds 2'), 'waiting is not a step')
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
test('buildCandidate: when names the project and the verification command', () => {
|
|
1314
|
+
const draft = C.buildCandidate(sampleTrace(), { cwd: 'D:\\work\\acme-app', sessionId: 'session-z' })
|
|
1315
|
+
assert.ok(draft.when.includes('acme-app'), draft.when)
|
|
1316
|
+
assert.ok(draft.when.includes('node'), draft.when)
|
|
1317
|
+
assert.notEqual(draft.when, '在 desktop 里 新增函数/模块并配用例')
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
test('decay report stays quiet while the library is nowhere near capacity', () => {
|
|
1321
|
+
const records = [{ id: 'exp-1', status: 'verified', title: 'x', uses: 1, at: new Date(NOW).toISOString() }]
|
|
1322
|
+
const report = D.renderDecayReport({ experiences: records }, NOW, { libraryLimit: 200 })
|
|
1323
|
+
assert.ok(report.includes('容量充足'), report)
|
|
1324
|
+
assert.ok(!report.includes('观察项'), report)
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1211
1327
|
// ---------------------------------------------------------------- summary
|
|
1212
1328
|
|
|
1213
1329
|
const total = pass + failures.length
|