dsh-experience 1.0.5 → 1.0.14

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/tools/e2e.mjs CHANGED
@@ -186,7 +186,7 @@ await test('T46 decay report is read-only and restore refuses unknown ids', asyn
186
186
  const report = await cmd('decay --age 400');
187
187
  assert.equal(report.kind, 'success', report.text);
188
188
  assert.equal(readStore().experiences.length, before, 'report must not delete anything');
189
- assert.equal(readStore().archive, undefined, 'nothing moved to archive');
189
+ assert.equal((readStore().archive || []).length, 0, 'nothing moved to archive');
190
190
  const restore = await cmd('restore exp-1');
191
191
  assert.equal(restore.kind, 'error');
192
192
  assert.match(restore.text, /archive 里没有/);
@@ -322,6 +322,94 @@ await test('T58 superseded is never recalled and never re-captured', async () =>
322
322
  assert.match(String(turn.capture && turn.capture.reason), /G8/, `expected G8, got ${JSON.stringify(turn.capture)}`);
323
323
  });
324
324
 
325
+ await test('T59 a run recipe never absorbs a write task', async () => {
326
+ const created = await tools.get('experience_record').execute(
327
+ {
328
+ title: '运行 node 脚本并抓退出码',
329
+ taskType: 'node/run',
330
+ when: '需要在 DSH 里跑 .mjs 脚本时',
331
+ keywords: ['acme-run', 'node', 'run'],
332
+ steps: ['跑 node tools/run.mjs', '读 EXIT'],
333
+ pitfalls: ['PATH 里没有 node,报 CommandNotFoundException'],
334
+ },
335
+ { agent: A },
336
+ )
337
+ const id = String(created).match(/exp-\d+/)[0]
338
+ const approved = await cmd(`approve ${id}`)
339
+ assert.equal(approved.kind, 'success', approved.text)
340
+
341
+ await enter(A, 21, 1, '在 src 里新增 quux 函数并配 node:test 用例')
342
+ await call(A, 'write', { file_path: 'D:\\work\\acme-run\\src\\quux.js' })
343
+ await call(A, 'write', { file_path: 'D:\\work\\acme-run\\test\\quux.test.mjs' })
344
+ await call(A, 'pwsh', { command: 'node test\\quux.test.mjs' })
345
+ await fire('agent/turn-stopping', { agent: A, turn: 21, signal: undefined })
346
+
347
+ const turn = readStore().turns.at(-1)
348
+ assert.ok(!turn.capture.merged, 'must not merge across intent classes')
349
+ assert.match(String(turn.capture.reason), /意图不同/, `reason: ${turn.capture.reason}`)
350
+ })
351
+
352
+ await test('T60 rejected records are hidden from list unless --all', async () => {
353
+ const created = await tools.get('experience_record').execute(
354
+ {
355
+ title: '新增 throwaway',
356
+ taskType: 'node-project/feature',
357
+ when: '一次性的试验任务',
358
+ keywords: ['throwaway'],
359
+ steps: ['写 a.js', '跑 a.js'],
360
+ pitfalls: [],
361
+ },
362
+ { agent: A },
363
+ )
364
+ const id = String(created).match(/exp-\d+/)[0]
365
+ const rejected = await cmd(`reject ${id}`)
366
+ assert.equal(rejected.kind, 'success', rejected.text)
367
+
368
+ const plain = await cmd('list')
369
+ assert.ok(!plain.text.includes(id), 'rejected hidden by default')
370
+ assert.match(plain.text, /rejected 已隐藏|--all/)
371
+
372
+ const all = await cmd('list --all')
373
+ assert.ok(all.text.includes(id), '--all shows the audit trail')
374
+
375
+ const debug = await tools.get('experience_debug').execute({}, { agent: A })
376
+ assert.match(debug, /rejected 1/)
377
+ })
378
+
379
+ await test('T61 a run-only turn becomes a run recipe', async () => {
380
+ await enter(A, 22, 1, '运行 D:\\work\\acme-run\\run.mjs 看输出')
381
+ await call(A, 'pwsh', { command: '& "C:\\Users\\dev\\node.exe" "D:\\work\\acme-run\\run.mjs"' })
382
+ await call(A, 'pwsh', { command: '& "C:\\Users\\dev\\node.exe" "D:\\work\\acme-run\\run.mjs"' })
383
+ await fire('agent/turn-stopping', { agent: A, turn: 22, signal: undefined })
384
+
385
+ const turn = readStore().turns.at(-1)
386
+ assert.equal(turn.capture.ok, true, JSON.stringify(turn.capture))
387
+ const withNode = readStore().experiences.filter((e) => ((e.fingerprint && e.fingerprint.commands) || []).includes('node'))
388
+ assert.ok(withNode.length >= 1, 'no record carries the node command head')
389
+ })
390
+
391
+ await test('T62 the same recipe in a second project stays separate', async () => {
392
+ await enter(A, 30, 1, '在 acme-one 里新增 lerp 函数并配 node:test 用例')
393
+ await call(A, 'write', { file_path: 'D:\\work\\acme-one\\src\\lerp.js' })
394
+ await call(A, 'write', { file_path: 'D:\\work\\acme-one\\test\\lerp.test.mjs' })
395
+ await call(A, 'pwsh', { command: 'node test\\lerp.test.mjs' })
396
+ await fire('agent/turn-stopping', { agent: A, turn: 30, signal: undefined })
397
+ const first = readStore().turns.at(-1).capture
398
+ assert.equal(first.ok, true, JSON.stringify(first))
399
+ const firstId = first.id
400
+ assert.ok(firstId, 'first project did not create a record')
401
+
402
+ await enter(A, 31, 1, '在 acme-two 里新增 lerp 函数并配 node:test 用例')
403
+ await call(A, 'write', { file_path: 'D:\\work\\acme-two\\src\\lerp.js' })
404
+ await call(A, 'write', { file_path: 'D:\\work\\acme-two\\test\\lerp.test.mjs' })
405
+ await call(A, 'pwsh', { command: 'node test\\lerp.test.mjs' })
406
+ await fire('agent/turn-stopping', { agent: A, turn: 31, signal: undefined })
407
+
408
+ const second = readStore().turns.at(-1).capture
409
+ assert.notEqual(second.merged, firstId, 'the second project must not merge into the first')
410
+ assert.match(String(second.reason), /scope 不同|意图不同|按新任务/, `reason: ${second.reason}`)
411
+ })
412
+
325
413
  console.log('');
326
414
  let pass = 0;
327
415
  results.forEach((item, index) => {
@@ -9,6 +9,11 @@ import * as C from '../lib/capture.js'
9
9
  import * as D from '../lib/decay.js'
10
10
  import * as E from '../lib/evidence.js'
11
11
  import * as L from '../lib/lookup.js'
12
+ import * as R from '../lib/recall.js'
13
+ import * as S from '../lib/store.js'
14
+ import fs from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
12
17
 
13
18
  let pass = 0
14
19
  const failures = []
@@ -41,6 +46,7 @@ test('config defaults are the agreed numbers', () => {
41
46
  stepsJaccard: 0.7,
42
47
  allowAutoVerified: false,
43
48
  minEffectiveSteps: 2,
49
+ recallThreshold: 0.15,
44
50
  })
45
51
  assert.equal(C.DEFAULT_CONFIG.autoPromote, false, 'policy must default to off')
46
52
  assert.equal(C.DEFAULT_CONFIG.allowAutoVerified, false, 'auto-verified must never be recalled by default')
@@ -912,6 +918,272 @@ test('T62 renderReview lists rule-rejected records without offering them', () =>
912
918
  assert.match(text, /已否决.*1 条/)
913
919
  })
914
920
 
921
+ // ---------------------------------------------------------------- recall scoring
922
+
923
+ test('T63 stop words do not dilute the query', () => {
924
+ const noisy = R.expand('随便跑一个测试场景,故意让它报错')
925
+ for (const filler of ['随便', '故意', '场景', '测试场景']) {
926
+ assert.equal(noisy.has(filler), false, filler + ' must be dropped')
927
+ }
928
+ assert.equal(noisy.has('#error'), true, 'feature survives even though the words are dropped')
929
+ })
930
+
931
+ test('T64 vocabulary mismatch still scores through feature buckets', () => {
932
+ const profile = 'pwsh node.exe load_workspace_dependencies 模块 报错'
933
+ const query = '跑一个会踩坑的 .mjs 脚本,内置 node 模块报错'
934
+ const result = R.explain(profile, query)
935
+ assert.ok(result.score >= 0.15, 'score ' + result.score + ' must clear the default floor')
936
+ assert.ok(result.features.includes('#node'), 'node feature must match, got ' + result.features.join(','))
937
+ assert.ok(result.features.includes('#error'), 'error feature must match, got ' + result.features.join(','))
938
+ })
939
+
940
+ test('T65 unrelated query still scores near zero', () => {
941
+ const result = R.explain('pwsh node.exe 模块 报错', '把三张图片重命名为 image_001.png')
942
+ assert.ok(result.score < 0.15, 'score ' + result.score + ' must stay below the floor')
943
+ })
944
+
945
+ test('T66 score is bounded and self-match is high', () => {
946
+ const profile = '新增 truncate 函数 并配 node:test 用例'
947
+ const partial = R.score(profile, R.expand('新增 truncate 函数并配用例'))
948
+ const self = R.score(profile, R.expand(profile))
949
+ assert.ok(partial > 0.3 && partial <= 1, 'partial match scored ' + partial)
950
+ assert.ok(self <= 1, 'self match must not exceed 1, got ' + self)
951
+ })
952
+
953
+ test('T67 tokenizer keeps file names', () => {
954
+ const tokens = R.tokenize('node test/truncate.test.mjs && echo done')
955
+ assert.equal(tokens.has('truncate.test.mjs'), true)
956
+ assert.equal(tokens.has('node'), true)
957
+ })
958
+
959
+ test('T68 a query with #error favours the record that documents the failure', () => {
960
+ const withPitfall = {
961
+ id: 'exp-a',
962
+ title: '在 DSH 会话里运行 Node 脚本',
963
+ taskType: 'node/run',
964
+ when: '在 desktop 里 跑 .mjs 脚本验证时',
965
+ keywords: ['node', 'node.exe', 'PATH', 'MODULE_NOT_FOUND'],
966
+ pitfalls: ['require 一个不存在的模块只会得到 MODULE_NOT_FOUND 报错'],
967
+ reverse_examples: [],
968
+ }
969
+ const chore = {
970
+ id: 'exp-b',
971
+ title: '新增 index',
972
+ taskType: 'node-project/feature',
973
+ when: '在 desktop 里 新增函数/模块并配用例',
974
+ keywords: ['node', 'src/index.js', 'test/<name>.mjs'],
975
+ pitfalls: [],
976
+ reverse_examples: [],
977
+ }
978
+ const index = R.corpus([withPitfall, chore])
979
+ const query = R.expand('随便写个 .mjs 脚本,require 一个不存在的模块,跑一下报错,然后自己排查修复')
980
+ const hit = R.score(R.profileText(withPitfall), query, index)
981
+ const miss = R.score(R.profileText(chore), query, index)
982
+ assert.equal(query.has('#error'), true, 'query must carry #error')
983
+ assert.ok(hit > miss, `pitfall record ${hit} must beat chore ${miss}`)
984
+ })
985
+
986
+ test('T69 reload preserves archive and evictions', () => {
987
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'exp-store-'))
988
+ const file = path.join(dir, 'store.json')
989
+ const store = S.emptyStore()
990
+ store.experiences = [{ id: 'exp-1', status: 'verified', title: 'kept' }]
991
+ store.archive = [{ id: 'exp-9', title: 'archived' }]
992
+ store.evictions = [{ id: 'exp-9', by: 'human-request', at: '2026-11-09T00:00:00.000Z' }]
993
+ store.nextId = 10
994
+ S.writeStore(file, store)
995
+
996
+ const reloaded = S.readStore(file)
997
+ assert.equal(reloaded.error, undefined, 'clean file must load without error')
998
+ assert.equal(reloaded.store.experiences.length, 1)
999
+ assert.deepEqual(reloaded.store.archive.map((e) => e.id), ['exp-9'], 'archive must survive')
1000
+ assert.equal(reloaded.store.evictions.length, 1, 'evictions must survive')
1001
+ assert.equal(reloaded.store.nextId, 10, 'counters must survive')
1002
+ fs.rmSync(dir, { recursive: true, force: true })
1003
+ })
1004
+
1005
+ test('T70 unknown fields survive a reload', () => {
1006
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'exp-store-'))
1007
+ const file = path.join(dir, 'store.json')
1008
+ fs.writeFileSync(file, JSON.stringify({ experiences: [], futureTable: [{ id: 'x' }] }), 'utf8')
1009
+ const reloaded = S.readStore(file)
1010
+ assert.deepEqual(reloaded.store.futureTable, [{ id: 'x' }], 'an older build must not drop new fields')
1011
+ assert.deepEqual(reloaded.store.archive, [], 'missing archive defaults to empty')
1012
+ fs.rmSync(dir, { recursive: true, force: true })
1013
+ })
1014
+
1015
+ test('T71 a corrupt store degrades instead of throwing', () => {
1016
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'exp-store-'))
1017
+ const file = path.join(dir, 'store.json')
1018
+ fs.writeFileSync(file, '{ not json', 'utf8')
1019
+ const result = S.readStore(file)
1020
+ assert.ok(result.error instanceof Error, 'error must be reported, not thrown')
1021
+ assert.equal(result.store.experiences.length, 0, 'falls back to an empty store')
1022
+ const missing = S.readStore(path.join(dir, 'nope.json'))
1023
+ assert.equal(missing.missing, true)
1024
+ assert.deepEqual(missing.store.archive, [])
1025
+ fs.rmSync(dir, { recursive: true, force: true })
1026
+ })
1027
+
1028
+ test('T72 a .png outcome never fingerprint-matches a .py recipe', () => {
1029
+ const python = {
1030
+ fingerprint: { targets: ['_img_edit/cand.py'], kinds: ['code', 'image'], green: true },
1031
+ }
1032
+ const pngOnly = {
1033
+ fingerprint: { targets: ['_img_edit/cand.py'], kinds: ['image'], green: true },
1034
+ }
1035
+ const imageOnly = { fingerprint: { targets: ['out/result.png'], kinds: ['image'], green: true } }
1036
+ const pythonOnly = { fingerprint: { targets: ['lib/mod.py'], kinds: ['code'], green: true } }
1037
+
1038
+ // same green flag and an overlapping target is not enough: types must overlap too
1039
+ assert.equal(C.fingerprintsMatch(pythonOnly, imageOnly), false, '.py must not merge a .png run')
1040
+ assert.equal(C.artifactTypesConflict(pythonOnly, imageOnly), true)
1041
+ // overlapping kinds (a run that touched both) still merges
1042
+ assert.equal(C.artifactTypesConflict(python, pngOnly), false)
1043
+ })
1044
+
1045
+ test('T73 artifactTypesConflict only vetoes when both sides carry evidence', () => {
1046
+ const code = { fingerprint: { targets: ['src/a.js'], kinds: ['code'], green: true } }
1047
+ const otherCode = { fingerprint: { targets: ['src/b.js'], kinds: ['code'], green: true } }
1048
+ const unknown = { fingerprint: { targets: ['<path>'], kinds: [], green: true } }
1049
+ const noFingerprint = {}
1050
+ assert.equal(C.artifactTypesConflict(code, otherCode), false, 'same kind never conflicts')
1051
+ assert.equal(C.artifactTypesConflict(code, unknown), false, 'unknown side must not veto')
1052
+ assert.equal(C.artifactTypesConflict(code, noFingerprint), false, 'legacy record must not veto')
1053
+ })
1054
+
1055
+ test('T74 outcomeFingerprint records the artifact kinds it saw', () => {
1056
+ const call = (hint) => ({ name: 'write', hint, ok: true })
1057
+ const py = C.outcomeFingerprint([call('D:/work/_img_edit/cand.py'), call('D:/work/_img_edit/out.png')])
1058
+ assert.deepEqual(py.kinds, ['code', 'image'], 'both kinds recorded')
1059
+ assert.deepEqual(py.targets, ['_img_edit/cand.py'], 'targets stay code-only for fingerprints')
1060
+
1061
+ const images = C.outcomeFingerprint([call('D:/work/out/a.png'), call('D:/work/out/b.jpg')])
1062
+ assert.deepEqual(images.kinds, ['image'])
1063
+ assert.deepEqual(images.targets, [], 'image-only run has no code targets')
1064
+
1065
+ assert.equal(C.artifactKindOf('x.JPG'), 'image', 'extension match is case-insensitive')
1066
+ assert.equal(C.artifactKindOf('noext'), '')
1067
+ })
1068
+
1069
+ test('T75 intent classes keep "run a script" apart from "write a function"', () => {
1070
+ const runRecipe = {
1071
+ title: '运行 node 脚本并抓退出码',
1072
+ when: '需要在 DSH 里跑 .mjs 脚本时',
1073
+ steps: ['跑 node tools/run.mjs'],
1074
+ keywords: ['node'],
1075
+ }
1076
+ const writeRecipe = {
1077
+ title: '新增 pad 函数并配用例',
1078
+ when: '需要加一个纯函数时',
1079
+ steps: ['写 src/pad.js'],
1080
+ keywords: ['node'],
1081
+ }
1082
+ assert.equal(R.intentOf(runRecipe), 'run')
1083
+ assert.equal(R.intentOf(writeRecipe), 'write')
1084
+ assert.equal(R.intentConflict(runRecipe, writeRecipe), true)
1085
+ assert.equal(R.intentConflict(writeRecipe, runRecipe), true)
1086
+ assert.equal(R.intentConflict(runRecipe, runRecipe), false)
1087
+ })
1088
+
1089
+ test('T76 an ambiguous intent never vetoes a merge', () => {
1090
+ const vague = { title: '处理一下那个东西', steps: ['看看'] }
1091
+ assert.equal(R.intentOf(vague), '')
1092
+ assert.equal(R.intentConflict(vague, { title: '新增函数' }), false)
1093
+ assert.equal(R.intentConflict({ title: '' }, { title: '' }), false)
1094
+ const mixed = { title: '新增脚本并运行它', steps: ['写 a.mjs', '跑 a.mjs'] }
1095
+ assert.equal(R.intentOf(mixed), '')
1096
+ assert.equal(R.intentConflict(mixed, { title: '新增函数' }), false)
1097
+ })
1098
+
1099
+ test('T77 rejected records are audit-only for capacity and eviction', () => {
1100
+ const now = Date.parse('2026-11-09T00:00:00Z')
1101
+ const old = (id, extra) => ({ id, status: 'candidate', createdAt: '2025-01-01T00:00:00Z', ...extra })
1102
+ const store = {
1103
+ experiences: [
1104
+ old('keep-verified', { status: 'verified' }),
1105
+ old('old-cand'),
1106
+ { id: 'rej-1', status: 'rejected', createdAt: '2025-01-01T00:00:00Z', title: 'rejected noise' },
1107
+ ],
1108
+ }
1109
+ const report = D.renderDecayReport(store, now, { libraryLimit: 10 })
1110
+ assert.match(report, /库 2 条/, report.split('\n')[0])
1111
+ assert.match(report, /已排除 1 条 rejected/)
1112
+ assert.ok(!report.includes('rej-1'), 'rejected must not appear in ranking')
1113
+ const proposal = D.selectEvictions(store.experiences, now, { libraryLimit: 2 })
1114
+ assert.ok(proposal.evictions.every((item) => item.id !== 'rej-1'), 'never evict a rejected record')
1115
+ })
1116
+
1117
+ test('T78 a pure run track is captured, titled and fingerprinted by its command', () => {
1118
+ const calls = [
1119
+ { name: 'pwsh', hint: 'New-Item -ItemType Directory -Path D:\\work\\acme-app\\cross-a', ok: true },
1120
+ { name: 'pwsh', hint: '& "C:\\Users\\dev\\node.exe" "D:\\work\\acme-app\\run.mjs"', ok: true },
1121
+ { name: 'pwsh', hint: '& "C:\\Users\\dev\\node.exe" "D:\\work\\acme-app\\run.mjs"', ok: true },
1122
+ ]
1123
+ const trace = { query: '运行 D:\\work\\acme-app\\run.mjs 看输出', calls }
1124
+ const gate = C.shouldCapture(trace, { todayAdded: 0, autoTotal: 0 }, C.DEFAULT_CONFIG, 'run')
1125
+ assert.equal(gate.ok, true, gate.reason)
1126
+ assert.equal(gate.runOnly, true)
1127
+ assert.equal(gate.executions, 2)
1128
+
1129
+ const quality = C.gateStepQuality(
1130
+ trace,
1131
+ { title: '跑 run', when: '当需要跑脚本时', steps: [] },
1132
+ C.DEFAULT_CONFIG,
1133
+ 'run',
1134
+ )
1135
+ assert.equal(quality.ok, true, quality.reason)
1136
+
1137
+ const draft = C.buildCandidate(trace, { runOnly: true }, C.DEFAULT_CONFIG)
1138
+ assert.equal(draft.title, '跑 run')
1139
+ assert.deepEqual(draft.fingerprint.commands, ['node'])
1140
+ assert.deepEqual(C.commandHeads(calls), ['node'])
1141
+ })
1142
+
1143
+ test('T79 mkdir and read-only tracks still fail G3 and G7', () => {
1144
+ const calls = [
1145
+ { name: 'pwsh', hint: 'New-Item -ItemType Directory -Path D:\\work\\acme-app\\cross-b', ok: true },
1146
+ { name: 'pwsh', hint: 'Get-Content D:\\work\\acme-app\\run.mjs', ok: true },
1147
+ { name: 'pwsh', hint: 'Get-ChildItem -Force', ok: true },
1148
+ ]
1149
+ const trace = { query: '跑一下 run.mjs', calls }
1150
+ const gate = C.shouldCapture(trace, { todayAdded: 0, autoTotal: 0 }, C.DEFAULT_CONFIG, 'run')
1151
+ assert.equal(gate.ok, false)
1152
+ assert.match(gate.reason, /G3/)
1153
+
1154
+ const quality = C.gateStepQuality(trace, { title: '跑 run', when: '当需要跑脚本时' }, C.DEFAULT_CONFIG, 'run')
1155
+ assert.equal(quality.ok, false)
1156
+ assert.match(quality.reason, /G7/)
1157
+
1158
+ const oneWrite = [{ name: 'write', hint: 'D:\\work\\x\\hello.js', ok: true }]
1159
+ const writeGate = C.gateStepQuality(
1160
+ { calls: oneWrite },
1161
+ { title: '新增 hello', when: '当需要新增函数时' },
1162
+ C.DEFAULT_CONFIG,
1163
+ 'write',
1164
+ )
1165
+ assert.equal(writeGate.ok, false, 'write tracks still need two effective steps')
1166
+ })
1167
+
1168
+ test('T80 a shared tool-chain token does not bridge two projects', () => {
1169
+ const left = { scope: ['cross-a', 'node'], title: '新增 math' }
1170
+ const right = { scope: ['cross-b', 'node', 'cr'], title: '新增 math' }
1171
+ assert.deepEqual(C.projectScope(left), ['cross-a'])
1172
+ assert.deepEqual(C.scopeConflicts(left, right), true)
1173
+ assert.deepEqual(C.scopeConflicts(left, { scope: ['cross-a'] }), false)
1174
+ // A record that only knows the tool-chain stays neutral, not conflicting.
1175
+ assert.equal(C.scopeConflicts({ scope: ['node'] }, right), false)
1176
+ })
1177
+
1178
+ test('T81 a run recipe is always titled by its script', () => {
1179
+ const calls = [
1180
+ { name: 'pwsh', hint: '& "C:\\Users\\dev\\node.exe" "D:\\work\\app\\run.mjs"', ok: true },
1181
+ { name: 'pwsh', hint: '& "C:\\Users\\dev\\node.exe" "D:\\work\\app\\run.mjs"', ok: true },
1182
+ ]
1183
+ const draft = C.buildCandidate({ query: '运行 run.mjs', calls }, { runOnly: true }, C.DEFAULT_CONFIG)
1184
+ assert.equal(draft.title, '跑 run')
1185
+ })
1186
+
915
1187
  // ---------------------------------------------------------------- summary
916
1188
 
917
1189
  const total = pass + failures.length