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 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 = 'r22-reminder'
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
- return R.score(R.profileText(experience), queryTokens, index)
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 = agentId(exec && exec.agent) || initiatorId()
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 >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, 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(trace.calls || []))
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 = agentId(payload && payload.agent)
728
- const calls = liveCalls.get(id) || []
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
- const now = simulated ? Date.now() + simulatedDays * 86400000 : Date.now()
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 when = clip(place ? `在 ${place} 里 ${verbsOf(taskType)}` : `当需要${verbsOf(taskType)}时`, 160)
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: mentionedDirs(
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((record) => record && record.status !== 'verified' && record.status !== 'rejected' && !isPinned(record))
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
- ? `建议归档 ${proposal.evictions.length} 条(最多 ${num(config.evictBatch, DECAY_DEFAULTS.evictBatch)} 条/次,按 rank 升序):`
205
- : '容量尚未告警: 以下为 rank 最低的 5 条观察项,无需处理。',
206
- ...(top.length
207
- ? top.map((entry, index) => ` ${index + 1}. ${decayLine(entry.record, now, config)}${eligible.has(entry.record.id) ? ' ← 可归档' : ''}`)
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
- // Durable store I/O, split out of index.js so the reload path is testable.
2
- //
3
- // The bug this exists to prevent: `loadStore()` used to rebuild only
4
- // {version,nextId,nextInjectionId,experiences,injections,turns}, so every
5
- // reload dropped `archive` and `evictions` — and the next persist() rewrote
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: 1,
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
- nextId: Number(parsed.nextId) || 1,
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
- return { store: normalizeStore(JSON.parse(raw)) }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-experience",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
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) => {
@@ -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
- assert.equal(report.split('\n').filter((row) => /^ {2}\d+\. /.test(row)).length, 2, 'pinned is excluded from the ranking')
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