dsh-experience 1.2.0 → 1.2.1

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,24 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [1.2.1] — 2026-11-09 — 审计修复:召回语料、id 回收、模拟淘汰、恢复、G8 守卫
5
+
6
+ ### 修复
7
+ - **召回漏传 IDF 语料**:`index.js` 召回处只传两个参数,`scoreExperience` 的第三参为空,所有词权重 1,常见词与稀有词同权——无关记录可能越过阈值被注入。现在与合并路径一致。
8
+ - **`nextId` 不与现有 id 对账**:载入时与 `experiences`/`archive` 的最大 `exp-N` 对齐,不再复用已存在的 id(否则 `forget`/`archive` 会打到旧记录)。
9
+ - **`decay --age N --apply` 用模拟未来时间真归档**:现在直接拒绝,`--age` 只用于展示。
10
+ - **`restore` 是空操作**:恢复时把 `lastUsedAt` 置为当前时刻,记录不会立刻重新判为 dormant。
11
+ - **淘汰会毁掉 G8 守卫**:`superseded` 不再进入淘汰候选(归档后同一指纹会被重新收录)。
12
+ - **“没什么可抓”被记成失败**:只有真的出现失败调用、且指纹与记录一致时,G3 拒绝才写 failure,普通对话不再毒化证据链。
13
+ - **临时文件名跨进程冲突**:`store.json.<pid>.tmp`,两个 DSH 进程同时写不再互相覆盖。
14
+ - **`pitfalls` 别名**:删除 `if (existing.pitfalls)`(`[]` 为真)导致 pitfalls 指向 reverse_examples 的写法。
15
+
16
+ ### 测试
17
+ - 新增 T87–T91、T63、T64;`node tools/selftest.mjs` 101 pass / 0 fail,`node tools/e2e.mjs` 25 pass / 0 fail。
18
+
19
+ ### 仍未做
20
+ - 工具在写盘失败时仍回“成功”(`persist` 吞异常);语料每轮仍重建 3 次;历史记录未回填 `pitfalls` 别名。
21
+
4
22
  ## [1.2.0] — 2026-11-09 — 计划任务召回与已知问题清单
5
23
 
6
24
  ### 修复
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 = 'r23-audit-fixes'
39
39
 
40
40
  let CONFIG = { ...C.DEFAULT_CONFIG }
41
41
 
@@ -291,7 +291,7 @@ export function apply(ctx) {
291
291
  const queryTokens = tokens(queryText)
292
292
  const index = R.corpus(store.experiences)
293
293
  const scored = store.experiences
294
- .map((experience) => ({ experience, score: scoreExperience(experience, queryTokens) }))
294
+ .map((experience) => ({ experience, score: scoreExperience(experience, queryTokens, index) }))
295
295
  .sort((a, b) => b.score - a.score)
296
296
  const candidates = scored.slice(0, 5).map(({ experience, score }) => ({
297
297
  id: experience.id,
@@ -522,15 +522,23 @@ export function apply(ctx) {
522
522
 
523
523
  function noteGatedFailure(trace) {
524
524
  try {
525
+ const calls = trace.calls || []
526
+ // Being refused for "nothing to capture" is not a failure of any recipe.
527
+ // A turn only voids a record's evidence chain when it actually hit an error
528
+ // while attempting that same job.
529
+ if (!calls.some((call) => call && call.ok === false)) return
530
+ const fingerprint = C.outcomeFingerprint(calls)
525
531
  const queryTokens = tokens(trace.query || '')
526
532
  const index = R.corpus(store.experiences)
527
533
  let best = null
528
534
  for (const existing of store.experiences) {
529
535
  if (!['verified', 'auto-verified', 'candidate'].includes(existing.status)) continue
530
536
  const score = scoreExperience(existing, queryTokens, index)
531
- if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
537
+ if (score < CONFIG.mergeThreshold) continue
538
+ if (!C.fingerprintsMatch(existing, { fingerprint })) continue
539
+ if (!best || score > best.score) best = { existing, score }
532
540
  }
533
- if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(trace.calls || []))
541
+ if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(calls))
534
542
  } catch (error) {
535
543
  console.warn('[experience] failure evidence failed:', error && error.message)
536
544
  }
@@ -1103,7 +1111,10 @@ export function apply(ctx) {
1103
1111
  const ageIndex = parts.indexOf('--age')
1104
1112
  const simulatedDays = ageIndex >= 0 ? Number(parts[ageIndex + 1]) : 0
1105
1113
  const simulated = Number.isFinite(simulatedDays) && simulatedDays > 0
1106
- const now = simulated ? Date.now() + simulatedDays * 86400000 : Date.now()
1114
+ if (simulated && parts.includes('--apply')) {
1115
+ return err('--age 只用于模拟展示,不能和 --apply 一起用:先用 --age 看水位,再不带 --age 执行 --apply')
1116
+ }
1117
+ const now = Date.now()
1107
1118
  if (!parts.includes('--apply')) {
1108
1119
  D.markDecay(store.experiences, Date.now(), CONFIG)
1109
1120
  persist()
package/lib/capture.js CHANGED
@@ -723,7 +723,6 @@ export function mergeInto(existing, draft) {
723
723
  }
724
724
  }
725
725
  existing.reverse_examples = [...canonical, ...added].slice(0, 10)
726
- if (existing.pitfalls) existing.pitfalls = existing.reverse_examples
727
726
 
728
727
  const stepSeen = new Set(existing.steps || [])
729
728
  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)
@@ -211,6 +220,9 @@ export function renderDecayReport(store, now = Date.now(), config = {}) {
211
220
  return lines.join('\n')
212
221
  }
213
222
 
223
+ export const ARCHIVE_LIMIT = 200
224
+ export const EVICTION_LIMIT = 500
225
+
214
226
  export function applyEvictions(store, now = Date.now(), config = {}, by = 'command') {
215
227
  const records = Array.isArray(store && store.experiences) ? store.experiences : []
216
228
  const proposal = selectEvictions(records, now, config)
@@ -240,6 +252,10 @@ export function applyEvictions(store, now = Date.now(), config = {}, by = 'comma
240
252
  ageDays: proposalItem ? proposalItem.ageDays : null,
241
253
  })
242
254
  }
255
+ // Archived records keep their whole body, so the list has to be bounded or the
256
+ // store grows forever on a long-lived machine.
257
+ if (store.archive.length > ARCHIVE_LIMIT) store.archive = store.archive.slice(-ARCHIVE_LIMIT)
258
+ if (store.evictions.length > EVICTION_LIMIT) store.evictions = store.evictions.slice(-EVICTION_LIMIT)
243
259
  return { archived: archived.map((record) => record.id), triggered: true, at, reason: `已归档 ${archived.length} 条(未物理删除,可用 /memory restore <id> 恢复)` }
244
260
  }
245
261
 
@@ -251,6 +267,9 @@ export function restoreRecord(store, id) {
251
267
  delete record.archivedAt
252
268
  delete record.archivedBy
253
269
  delete record.decay
270
+ // Resetting the snapshot alone would decay again on the next pass: age is
271
+ // measured from the last use, so a restore has to count as a use.
272
+ record.lastUsedAt = new Date(Date.now()).toISOString()
254
273
  store.experiences.push(record)
255
274
  store.evictions = Array.isArray(store.evictions) ? store.evictions : []
256
275
  store.evictions.push({ id, at: new Date().toISOString(), by: 'restore', reason: '人工恢复' })
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.1",
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) => {
@@ -1208,6 +1208,85 @@ test('T84 a scheduled wrapper yields the task, not the wrapper', () => {
1208
1208
  assert.equal(R.stripReminder('普通任务'), '普通任务')
1209
1209
  })
1210
1210
 
1211
+ test('T85 an unchanged store is not rewritten', () => {
1212
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-store-'))
1213
+ const file = path.join(dir, 'store.json')
1214
+ S.resetWriteCache()
1215
+ const store = S.emptyStore()
1216
+ assert.equal(S.writeStore(file, store).written, true)
1217
+ assert.equal(S.writeStore(file, store).written, false)
1218
+ store.nextId = 7
1219
+ assert.equal(S.writeStore(file, store).written, true)
1220
+ const stats = S.writeStats()
1221
+ assert.equal(stats.writes, 2)
1222
+ assert.equal(stats.skipped, 1)
1223
+ // A read seeds the cache, so "load then persist an untouched snapshot" is free.
1224
+ S.resetWriteCache()
1225
+ const reread = S.readStore(file)
1226
+ assert.equal(S.writeStore(file, reread.store).written, false)
1227
+ fs.rmSync(dir, { recursive: true, force: true })
1228
+ })
1229
+
1230
+ test('T86 the archive keeps the newest 200 records', () => {
1231
+ assert.equal(D.ARCHIVE_LIMIT, 200)
1232
+ const store = { experiences: [], archive: [], evictions: [], nextId: 1 }
1233
+ for (let i = 0; i < 205; i += 1) {
1234
+ store.experiences.push({ id: 'exp-' + i, status: 'candidate', createdAt: '2020-01-01T00:00:00.000Z', steps: ['a', 'b'] })
1235
+ }
1236
+ const result = D.applyEvictions(store, Date.now(), { ...C.DEFAULT_CONFIG, libraryLimit: 10 }, 'test')
1237
+ assert.ok(result.archived.length > 0)
1238
+ assert.ok(store.archive.length <= D.ARCHIVE_LIMIT)
1239
+ assert.equal(store.archive.at(-1).archivedBy, 'test')
1240
+ assert.ok(store.evictions.length <= D.EVICTION_LIMIT)
1241
+ })
1242
+
1243
+ test('T87 recall scores with the IDF corpus', () => {
1244
+ const source = fs.readFileSync(new URL('../index.js', import.meta.url), 'utf8')
1245
+ assert.ok(source.includes('scoreExperience(experience, queryTokens, index)'), 'recall passes the corpus')
1246
+ assert.ok(!source.includes('scoreExperience(experience, queryTokens)'), 'no call site drops the corpus')
1247
+ })
1248
+
1249
+ test('T88 a stale nextId is reconciled with existing ids', () => {
1250
+ const store = S.normalizeStore({
1251
+ nextId: 3,
1252
+ experiences: [{ id: 'exp-1' }, { id: 'exp-9' }],
1253
+ archive: [{ id: 'exp-12' }],
1254
+ })
1255
+ assert.equal(store.nextId, 13)
1256
+ const empty = S.normalizeStore({ experiences: [] })
1257
+ assert.equal(empty.nextId, 1)
1258
+ })
1259
+
1260
+ test('T89 superseded records are never evicted', () => {
1261
+ const now = Date.parse('2026-11-09T00:00:00Z')
1262
+ const old = new Date(now - 400 * 86400000).toISOString()
1263
+ const records = []
1264
+ for (let i = 0; i < 159; i += 1) {
1265
+ records.push({ id: 'exp-' + (100 + i), status: 'candidate', createdAt: old })
1266
+ }
1267
+ records.push({ id: 'exp-super', status: 'superseded', createdAt: old })
1268
+ const result = D.selectEvictions(records, now, {})
1269
+ assert.equal(result.triggered, true)
1270
+ assert.ok(result.evictions.length > 0, 'old candidates are proposed')
1271
+ assert.ok(!result.evictions.some((item) => item.id === 'exp-super'), 'the G8 guard stays in the library')
1272
+ })
1273
+
1274
+ test('T90 restore makes a record young again', () => {
1275
+ const now = Date.parse('2026-11-09T00:00:00Z')
1276
+ const old = new Date(now - 400 * 86400000).toISOString()
1277
+ const store = { experiences: [], archive: [{ id: 'exp-old', status: 'candidate', createdAt: old, steps: [] }], evictions: [] }
1278
+ const restored = D.restoreRecord(store, 'exp-old')
1279
+ assert.ok(restored, 'record comes back')
1280
+ D.markDecay(store.experiences, now, {})
1281
+ const decay = store.experiences[0].decay
1282
+ assert.equal(decay.state, 'active')
1283
+ assert.equal(decay.ageDays, 0)
1284
+ })
1285
+
1286
+ test('T91 a fresh store starts at schema version 2', () => {
1287
+ assert.equal(S.emptyStore().version, 2)
1288
+ })
1289
+
1211
1290
  // ---------------------------------------------------------------- summary
1212
1291
 
1213
1292
  const total = pass + failures.length