dsh-experience 1.0.8 → 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/CHANGELOG.md +119 -0
- package/README.md +3 -3
- package/index.js +78 -13
- package/lib/capture.js +148 -12
- package/lib/decay.js +9 -4
- package/lib/recall.js +25 -0
- package/package.json +1 -1
- package/tools/e2e.mjs +88 -0
- package/tools/selftest.mjs +159 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,124 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
|
|
4
|
+
## [1.0.14] — 2026-11-09 — A/B 效率实测记录
|
|
5
|
+
|
|
6
|
+
### 记录
|
|
7
|
+
- `docs/TESTING.md` 新增第 7 节:冷/热两轮对照(`recallThreshold` 1.0 vs 0.15)的完整表格、
|
|
8
|
+
两轮原始调用清单、`inj-52` 注入记录,以及六条偏差声明(n=1、顺序效应、编辑粒度、夹具复用、
|
|
9
|
+
同族不同题、环境一致)。
|
|
10
|
+
- 实测:调用 18 → 9(−50%)、耗时 80.4s → 32.3s(−60%)、注入 524 token、重复踩坑 0 次。
|
|
11
|
+
|
|
12
|
+
### 明确不宣称
|
|
13
|
+
- 不把单轮对照当作"平均提升"。要支持平均值需要每格 ≥5 轮并随机化顺序。
|
|
14
|
+
|
|
15
|
+
### 维护
|
|
16
|
+
- 清理验收期夹具目录(cache-lab、cross-A/B、intent-test、exp-bench2/3/4 等),保留 `exp-bench`
|
|
17
|
+
作为 v0.1 闭环的历史证据。
|
|
18
|
+
|
|
19
|
+
## [1.0.13] — 2026-11-09 — 跨项目合并回归:scope 被工具链 token 桥接
|
|
20
|
+
|
|
21
|
+
### 修复
|
|
22
|
+
- 三轮实跑验证发现:同配方换项目仍被「结果指纹」合并。根因是 `mentionedDirs()` 把
|
|
23
|
+
`…\node\bin\node.exe` 的最深段 `node` 也当作目录,而 `scopeConflicts()` 用集合交集判断同项目,
|
|
24
|
+
于是 `[cross-a, node]` 与 `[cross-b, node]` 因共享 `node` 被判为同一项目。
|
|
25
|
+
- `GENERIC_DIRS` 增补工具链目录(node/python/npm/pnpm/git/pwsh/deno/runtime/dependencies/dsh…)。
|
|
26
|
+
- 新增 `projectScope(record)`:比较前先滤掉通用目录;`scopeConflicts()` 改用它。
|
|
27
|
+
- run 型记录标题改为始终「跑 <script>」(此前仅 `runOnly` 时为该格式,混合轨迹会退回「跑通 node <name>」)。
|
|
28
|
+
|
|
29
|
+
### 测试
|
|
30
|
+
- T80:`cross-a` 与 `cross-b` 共享 `node` 时仍判冲突;只知工具链的记录保持中立。
|
|
31
|
+
- T81:run 型标题恒为「跑 run」。
|
|
32
|
+
- e2e T62:同一配方在两个项目里必须各自建档,不得合并。
|
|
33
|
+
- `selftest 91 pass / 0 fail`、`e2e 23 pass / 0 fail`。
|
|
34
|
+
|
|
35
|
+
### 说明
|
|
36
|
+
- v1.0.12 及更早版本中「跨目录已验证」的结论不成立,本次修正后需重跑一次轮 2 才算复验。
|
|
37
|
+
- 文档:`docs/TESTING.md` 第 6 节、`ACCEPTANCE.md` 第 10 节记录了三轮原始日志与本次回归。
|
|
38
|
+
|
|
39
|
+
## [1.0.12] — 2026-11-09 — 纯 run 轨迹可以被捕获了
|
|
40
|
+
|
|
41
|
+
### 修复
|
|
42
|
+
- 此前 `stepPriority` 只把「写」算 3 分、「测试执行」算 4 分,普通命令只有 2 分,
|
|
43
|
+
所以「写一个文件 + 跑一次」永远只有 1 个有效步骤,G7 直接拦截——`scope` 与 `intent` 两道闸门根本轮不到执行。
|
|
44
|
+
- `shouldCapture(trace, counts, config, intent)`:`intent === "run"` 时,只要成功执行 ≥2 次(建目录/读文件不计)即可过 G3。
|
|
45
|
+
- `gateStepQuality(..., intent)`:run 型轨迹改用 `runSteps()` 计量,理由串写明「可复用执行(建目录/读文件不计)」。
|
|
46
|
+
- `buildCandidate()`:run 型记录标题改为「跑 `<script>`」,产物名取命令里真实的脚本名。
|
|
47
|
+
- 指纹新增 `commands`(`commandHeads()`),记录实际启动的程序首词:`& "…\node.exe" x.mjs` → `node`。
|
|
48
|
+
|
|
49
|
+
### 测试
|
|
50
|
+
- T78:纯 run 轨迹(mkdir + 两次 node 执行)过 G3/G7,标题 `跑 run`,指纹 `commands: ["node"]`。
|
|
51
|
+
- T79:只有 mkdir/Get-Content 的轨迹仍被 G3 拒;write 型轨迹仍要求两个有效步骤(回归)。
|
|
52
|
+
- e2e T61:整轮只有两次 `pwsh` 执行时,capture 通过并落库。
|
|
53
|
+
- `selftest 89 pass / 0 fail`、`e2e 22 pass / 0 fail`。
|
|
54
|
+
|
|
55
|
+
### 说明
|
|
56
|
+
- 这是「保守版」:只放宽 run 意图,write 意图的阈值一字未动;mkd/读文件仍被 `isNoiseStep` 挡在计量之外。
|
|
57
|
+
- 夹具 `cross-A` / `cross-B` 已就绪(`src/math.js` + `test/math.test.mjs` + `run.mjs`),用于验证 scope 与 intent 拦截。
|
|
58
|
+
|
|
59
|
+
## [1.0.11] — 2026-11-09 — rejected 记录退出计数与容量口径
|
|
60
|
+
|
|
61
|
+
### 体验问题
|
|
62
|
+
- 被 /memory reject 掉的记录仍被算进「库 N 条」和衰减报告,把数字撑得不像实话。
|
|
63
|
+
|
|
64
|
+
### 修复
|
|
65
|
+
- `/memory debug` 的库计数改为活动库条数,并单列 `rejected N`。
|
|
66
|
+
- `/memory decay` 报告与 `selectEvictions()` 都跳过 `rejected`:它们不参与排序、不占容量、永不归档。
|
|
67
|
+
报告末尾显示「已排除 N 条 rejected(审计留档…)」。
|
|
68
|
+
- `/memory list` 默认隐藏 rejected;`/memory list --all` 才显示,隐藏时给出提示行。
|
|
69
|
+
|
|
70
|
+
### 测试
|
|
71
|
+
- T77:库计数只算 2 条、报告出现「已排除 1 条」、rejected 不进排行、不会被 selectEvictions 选中。
|
|
72
|
+
- e2e T60:reject 后 list 不显示、`list --all` 显示、debug 出现 `rejected 1`。
|
|
73
|
+
- `selftest 87 pass / 0 fail`、`e2e 21 pass / 0 fail`。
|
|
74
|
+
|
|
75
|
+
### 说明
|
|
76
|
+
- rejected 仍保留在 store.json 里作为审计证据,也能被 `supersede`/`restore` 流程引用;只是不再参与任何统计与容量判断。
|
|
77
|
+
|
|
78
|
+
## [1.0.10] — 2026-11-09 — 意图桶分离:run 与 write 不再互相吸收
|
|
79
|
+
|
|
80
|
+
### 修复
|
|
81
|
+
- 合并前先比意图类:`R.intentOf()` 把文本归为 run(跑 / 运行 / 执行)或 write(新增 / 实现 / 修改),
|
|
82
|
+
`R.intentConflict()` 只在两侧都有明确意图且不同时返回 true。
|
|
83
|
+
- 两条合并路径(结果指纹、词面计分)都先过 `intentBlocked()`;被拦下时按新任务建档,
|
|
84
|
+
reason 追加「意图不同(run vs write),不并入 exp-N」。
|
|
85
|
+
- 任一侧意图含糊(同时出现 run 与 write,或都没有)不否决——不确定时宁可合并,避免库碎裂。
|
|
86
|
+
|
|
87
|
+
### 测试
|
|
88
|
+
- T75:run 配方与 write 任务互相冲突,同类不冲突。
|
|
89
|
+
- T76:含糊 / 空文本永不否决。
|
|
90
|
+
- e2e T59:库里已有 run 型 verified 经验时,write 型任务照旧新建,且 reason 写明意图不同。
|
|
91
|
+
- `selftest 86 pass / 0 fail`、`e2e 20 pass / 0 fail`。
|
|
92
|
+
|
|
93
|
+
### 说明
|
|
94
|
+
- 这条闸门针对「同一批名词、不同活」的误合并(例如「运行图片处理脚本」与「写一个图片处理脚本」)。
|
|
95
|
+
它不替代产物类型闸门:类型冲突先拦,意图冲突再拦。
|
|
96
|
+
- exp-30 因两次误合并导致 occurrences 虚高,已归档(`store.archive` + `evictions` 审计,可 `/memory restore`)。
|
|
97
|
+
|
|
98
|
+
## [1.0.9] — 2026-11-09 — 产物类型闸门(跨类型不合并)
|
|
99
|
+
|
|
100
|
+
现象:一个「把图中 54+188 改成 91」的图片编辑任务,被合并进一条已有经验的轨迹里,
|
|
101
|
+
occ 从 1 变 2。产出「全绿」加上词面相似度就足以命中合并,**没人在看产出是什么类型**。
|
|
102
|
+
|
|
103
|
+
根因(两条路径都要挡):
|
|
104
|
+
|
|
105
|
+
1. 指纹匹配只看文件名与 green,.png 产物不入 targets,于是「双方都有绿 + 某个名字相同」即可合并;
|
|
106
|
+
2. 词面合并(mergeThreshold)根本不关心产物类型——本次真实合并其实是这条路
|
|
107
|
+
(byFingerprint: false,score 0.761 / 0.891),不是指纹。
|
|
108
|
+
|
|
109
|
+
修法:
|
|
110
|
+
|
|
111
|
+
- 新增 artifactKindOf() / artifactKindsOf() / artifactTypesConflict():按扩展名把产物分成
|
|
112
|
+
code / image / doc / binary 四类;两侧都有类型证据且**无交集**即判冲突。
|
|
113
|
+
- outcomeFingerprint() 现在记录 kinds(仍保留 targets 的代码文件语义,兼容旧记录)。
|
|
114
|
+
- fingerprintsMatch() 增加类型前置条件:同名不同类型不算同一结果。
|
|
115
|
+
- runCapture() 对**词面合并**同样加类型闸门,冲突时不合并、按新任务建档,
|
|
116
|
+
并在 reason 里写明「产物类型不同(code vs image),不并入 exp-N(相似度 X)」。
|
|
117
|
+
- 旧记录在 loadStore() 时由 targets 反推 kinds(幂等),让闸门对历史库同样生效。
|
|
118
|
+
|
|
119
|
+
测试:T72(.png 结果不得与 .py 配方指纹合并)、T73(仅一侧有类型证据时不否决)、
|
|
120
|
+
T74(outcomeFingerprint 正确记录 kinds 与 targets)。合计 selftest 84 pass、e2e 19 pass。
|
|
121
|
+
|
|
3
122
|
## [1.0.8] — 2026-11-09 — CI 测试与持久化回归
|
|
4
123
|
|
|
5
124
|
### 新增
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> **带生命周期治理与指纹防伪的 DSH 跨会话自学习插件** —— 同类任务第一次慢,第二次直接用上第一次的经验,但绝不把一次偶然的变通当成真理。
|
|
4
4
|
|
|
5
|
-
[](tools/selftest.mjs)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/Aatzer0/dsh-experience)
|
|
8
8
|
|
|
@@ -97,7 +97,7 @@ plugin_manager install_bundle target=link:<PATH-TO-dsh-experience>
|
|
|
97
97
|
| `/memory decay [--apply]` | 只读水位报告;`--apply` 归档(可用 `restore` 恢复) |
|
|
98
98
|
| `/memory supersede <id> [原因]` | 标记与当前规则冲突:永不召回,同指纹的轨迹也不再收录 |
|
|
99
99
|
|
|
100
|
-
完整命令见 [USER-GUIDE.md](USER-GUIDE.md)。
|
|
100
|
+
完整命令见 [USER-GUIDE.md](USER-GUIDE.md),完整教程见 [docs/USAGE.md](docs/USAGE.md)。
|
|
101
101
|
|
|
102
102
|
## 配置
|
|
103
103
|
|
|
@@ -141,7 +141,7 @@ plugin_manager install_bundle target=link:<PATH-TO-dsh-experience>
|
|
|
141
141
|
## 开发
|
|
142
142
|
|
|
143
143
|
```bash
|
|
144
|
-
node tools/selftest.mjs #
|
|
144
|
+
node tools/selftest.mjs # 91 pass / 0 fail(纯函数 + 闸门 + 衰减)
|
|
145
145
|
node tools/e2e.mjs # 19 pass / 0 fail(假 Cordis context 驱动真实 apply())
|
|
146
146
|
```
|
|
147
147
|
|
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 = 'r20-scope-tools'
|
|
39
39
|
|
|
40
40
|
let CONFIG = { ...C.DEFAULT_CONFIG }
|
|
41
41
|
|
|
@@ -82,6 +82,23 @@ function loadStore() {
|
|
|
82
82
|
// Records written before the redaction fix may still carry absolute paths.
|
|
83
83
|
let scrubbed = 0
|
|
84
84
|
for (const record of store.experiences) if (C.scrubRecord(record)) scrubbed += 1
|
|
85
|
+
// Records written before 1.0.9 carry no artifact kinds; derive them so the
|
|
86
|
+
// cross-type veto also protects the existing library.
|
|
87
|
+
let kindsBackfilled = 0
|
|
88
|
+
for (const record of store.experiences) {
|
|
89
|
+
if (record.fingerprint && !Array.isArray(record.fingerprint.kinds)) {
|
|
90
|
+
record.fingerprint.kinds = C.artifactKindsOf(record.fingerprint.targets)
|
|
91
|
+
kindsBackfilled += 1
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Write it back once: otherwise the guard only sees kinds after the next merge.
|
|
95
|
+
if (kindsBackfilled) {
|
|
96
|
+
try {
|
|
97
|
+
saveStore(store)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.warn('[dsh-experience] could not persist fingerprint kinds:', error && error.message)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
85
102
|
if (scrubbed) console.warn(`[experience] scrubbed ${scrubbed} legacy record(s)`)
|
|
86
103
|
return store
|
|
87
104
|
}
|
|
@@ -526,7 +543,8 @@ export function apply(ctx) {
|
|
|
526
543
|
(item) => item.source && item.source.auto && String(item.source.at || '').slice(0, 10) === today,
|
|
527
544
|
).length
|
|
528
545
|
|
|
529
|
-
const
|
|
546
|
+
const intent = R.intentOf(trace.query || '')
|
|
547
|
+
const gate = C.shouldCapture(trace, { todayAdded, autoTotal }, CONFIG, intent)
|
|
530
548
|
if (!gate.ok) {
|
|
531
549
|
outcome.reason = gate.reason
|
|
532
550
|
if (String(gate.reason).startsWith('G3')) noteGatedFailure(trace)
|
|
@@ -537,7 +555,13 @@ export function apply(ctx) {
|
|
|
537
555
|
|
|
538
556
|
const draft = C.buildCandidate(
|
|
539
557
|
trace,
|
|
540
|
-
{
|
|
558
|
+
{
|
|
559
|
+
cwd: workspaceOf(agent),
|
|
560
|
+
sessionId: trace.sessionId,
|
|
561
|
+
agentId: trace.sessionId,
|
|
562
|
+
turn: trace.turn,
|
|
563
|
+
runOnly: Boolean(gate.runOnly),
|
|
564
|
+
},
|
|
541
565
|
CONFIG,
|
|
542
566
|
)
|
|
543
567
|
|
|
@@ -554,7 +578,7 @@ export function apply(ctx) {
|
|
|
554
578
|
}
|
|
555
579
|
|
|
556
580
|
// G7: the capture has to read as a recipe, not as shell transcript fragments.
|
|
557
|
-
const quality = C.gateStepQuality(trace, draft, CONFIG)
|
|
581
|
+
const quality = C.gateStepQuality(trace, draft, CONFIG, intent)
|
|
558
582
|
if (!quality.ok) {
|
|
559
583
|
trace.skipReason = quality.reason
|
|
560
584
|
outcome.reason = quality.reason
|
|
@@ -578,10 +602,20 @@ export function apply(ctx) {
|
|
|
578
602
|
const mergeable = ['verified', 'auto-verified', 'candidate']
|
|
579
603
|
const runSteps = C.summarizeCalls(trace.calls || [])
|
|
580
604
|
|
|
605
|
+
// Intent classes must agree: a "run this pipeline" task is not the same
|
|
606
|
+
// job as "write a new function", whatever words they share.
|
|
607
|
+
let intentVeto = null
|
|
608
|
+
const intentBlocked = (existing) => {
|
|
609
|
+
if (!R.intentConflict(existing, draft)) return false
|
|
610
|
+
if (!intentVeto) intentVeto = { id: existing.id, a: R.intentOf(existing), b: R.intentOf(draft) }
|
|
611
|
+
return true
|
|
612
|
+
}
|
|
613
|
+
|
|
581
614
|
let fpBest = null
|
|
582
615
|
for (const existing of store.experiences) {
|
|
583
616
|
if (!mergeable.includes(existing.status)) continue
|
|
584
617
|
if (!sameScope(existing)) continue
|
|
618
|
+
if (intentBlocked(existing)) continue
|
|
585
619
|
if (!C.fingerprintsMatch(existing, draft)) continue
|
|
586
620
|
if (!fpBest) fpBest = existing
|
|
587
621
|
}
|
|
@@ -591,6 +625,7 @@ export function apply(ctx) {
|
|
|
591
625
|
if (!mergeable.includes(existing.status)) continue
|
|
592
626
|
if (!sameScope(existing)) continue
|
|
593
627
|
if (existing.taskType && draft.taskType && existing.taskType !== draft.taskType) continue
|
|
628
|
+
if (intentBlocked(existing)) continue
|
|
594
629
|
const score = scoreExperience(existing, queryTokens, index)
|
|
595
630
|
if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
|
|
596
631
|
}
|
|
@@ -598,6 +633,20 @@ export function apply(ctx) {
|
|
|
598
633
|
|
|
599
634
|
// A fingerprint match is the stronger evidence: same artifact, green tests.
|
|
600
635
|
// Prefer it over a lexical score so the run is recorded as byFingerprint.
|
|
636
|
+
// Artifact-type veto applies to lexical merges too, not just fingerprints.
|
|
637
|
+
let typeVeto = null
|
|
638
|
+
if (best && C.artifactTypesConflict(best.existing, draft)) {
|
|
639
|
+
typeVeto = {
|
|
640
|
+
id: best.existing.id,
|
|
641
|
+
score: best.score,
|
|
642
|
+
kinds: [
|
|
643
|
+
(best.existing.fingerprint && best.existing.fingerprint.kinds) || [],
|
|
644
|
+
(draft.fingerprint && draft.fingerprint.kinds) || [],
|
|
645
|
+
],
|
|
646
|
+
}
|
|
647
|
+
best = null
|
|
648
|
+
}
|
|
649
|
+
|
|
601
650
|
const target = fpBest
|
|
602
651
|
? { existing: fpBest, score: 0, byFingerprint: true }
|
|
603
652
|
: best
|
|
@@ -626,7 +675,7 @@ export function apply(ctx) {
|
|
|
626
675
|
noteEvidence(created, trace, 'success', Boolean(draft.fingerprint && draft.fingerprint.green), runSteps)
|
|
627
676
|
outcome.ok = true
|
|
628
677
|
outcome.id = id
|
|
629
|
-
outcome.reason = `新建 ${id}(scope ${(draft.scope || []).join(',') || '未知'};指纹 ${((draft.fingerprint && draft.fingerprint.targets) || []).join(',') || '无'}${draft.fingerprint && draft.fingerprint.green ? ' 全绿' : ''})`
|
|
678
|
+
outcome.reason = `新建 ${id}(scope ${(draft.scope || []).join(',') || '未知'};指纹 ${((draft.fingerprint && draft.fingerprint.targets) || []).join(',') || '无'}${draft.fingerprint && draft.fingerprint.green ? ' 全绿' : ''}${intentVeto ? `;意图不同(${intentVeto.a} vs ${intentVeto.b}),不并入 ${intentVeto.id}` : ''}${typeVeto ? `;产物类型不同(${typeVeto.kinds[0].join(',') || '无'} vs ${typeVeto.kinds[1].join(',') || '无'}),不并入 ${typeVeto.id}(相似度 ${typeVeto.score})` : ''})`
|
|
630
679
|
outcome.ms = Math.round(performance.now() - started)
|
|
631
680
|
persist()
|
|
632
681
|
return outcome
|
|
@@ -852,7 +901,7 @@ export function apply(ctx) {
|
|
|
852
901
|
const head = [
|
|
853
902
|
`store: ${STORE_FILE}`,
|
|
854
903
|
`build: ${BUILD} config: ${JSON.stringify(CONFIG)}`,
|
|
855
|
-
`experiences: ${store.experiences.length} (verified ${store.experiences.filter((item) => item.status === 'verified').length}, auto-verified ${store.experiences.filter((item) => item.status === 'auto-verified').length}, candidate ${store.experiences.filter((item) => item.status === 'candidate').length}, auto-captured ${store.experiences.filter((item) => item.source && item.source.auto).length})`,
|
|
904
|
+
`experiences: ${store.experiences.filter((x) => x.status !== 'rejected').length} (verified ${store.experiences.filter((item) => item.status === 'verified').length}, auto-verified ${store.experiences.filter((item) => item.status === 'auto-verified').length}, candidate ${store.experiences.filter((item) => item.status === 'candidate').length}, rejected ${store.experiences.filter((x) => x.status === 'rejected').length}, auto-captured ${store.experiences.filter((item) => item.source && item.source.auto).length})`,
|
|
856
905
|
`injections: ${store.injections.length} turns: ${store.turns.length} captures: ${captures.length} avgCaptureMs: ${avgMs}`,
|
|
857
906
|
`lastInjection: ${lastInjection ? `${lastInjection.id} nonce=${lastInjection.nonce} rendered=${lastInjection.rendered} tokens=${lastInjection.tokens} ids=${lastInjection.experienceIds.join(',')}` : '-'}`,
|
|
858
907
|
'',
|
|
@@ -943,9 +992,20 @@ export function apply(ctx) {
|
|
|
943
992
|
switch (verb) {
|
|
944
993
|
case '':
|
|
945
994
|
case 'list': {
|
|
946
|
-
|
|
995
|
+
const showAll = /^(--all|all)$/.test(String(arg || '').trim())
|
|
996
|
+
const visible = showAll ? store.experiences : store.experiences.filter((item) => item.status !== 'rejected')
|
|
997
|
+
const rejectedCount = store.experiences.filter((item) => item.status === 'rejected').length
|
|
998
|
+
if (!visible.length) {
|
|
999
|
+
return ok(
|
|
1000
|
+
store.experiences.length
|
|
1001
|
+
? `活动库为空;${rejectedCount} 条 rejected 记录只作审计(/memory list --all 查看)。`
|
|
1002
|
+
: '档案为空。用 /memory teach 或让 agent 调用 experience_record 记录第一条。',
|
|
1003
|
+
)
|
|
1004
|
+
}
|
|
1005
|
+
const header = !showAll && rejectedCount ? `(另有 ${rejectedCount} 条 rejected 已隐藏:/memory list --all)\n` : ''
|
|
947
1006
|
return ok(
|
|
948
|
-
|
|
1007
|
+
header +
|
|
1008
|
+
visible
|
|
949
1009
|
.map((item) => {
|
|
950
1010
|
const origin = item.source && item.source.auto ? 'auto' : 'manual'
|
|
951
1011
|
const count = (item.reverse_examples || item.pitfalls || []).length
|
|
@@ -1064,7 +1124,7 @@ export function apply(ctx) {
|
|
|
1064
1124
|
const lines = [
|
|
1065
1125
|
`store: ${STORE_FILE}`,
|
|
1066
1126
|
`build: ${BUILD} config: ${JSON.stringify(CONFIG)}`,
|
|
1067
|
-
`experiences: ${store.experiences.length} (verified ${store.experiences.filter((i) => i.status === 'verified').length}, auto-verified ${store.experiences.filter((i) => i.status === 'auto-verified').length}, candidate ${store.experiences.filter((i) => i.status === 'candidate').length}, auto-captured ${store.experiences.filter((i) => i.source && i.source.auto).length})`,
|
|
1127
|
+
`experiences: ${store.experiences.filter((x) => x.status !== 'rejected').length} (verified ${store.experiences.filter((i) => i.status === 'verified').length}, auto-verified ${store.experiences.filter((i) => i.status === 'auto-verified').length}, candidate ${store.experiences.filter((i) => i.status === 'candidate').length}, rejected ${store.experiences.filter((x) => x.status === 'rejected').length}, auto-captured ${store.experiences.filter((i) => i.source && i.source.auto).length})`,
|
|
1068
1128
|
`injections: ${store.injections.length} turns: ${store.turns.length} captures: ${captures.length} avgCaptureMs: ${avgMs}`,
|
|
1069
1129
|
`lastInjection: ${lastInjection ? `${lastInjection.id} nonce=${lastInjection.nonce} rendered=${lastInjection.rendered} tokens=${lastInjection.tokens} ids=${lastInjection.experienceIds.join(',')}` : '-'}`,
|
|
1070
1130
|
'',
|
|
@@ -1145,10 +1205,15 @@ export function apply(ctx) {
|
|
|
1145
1205
|
'[1 存储] ' + pass(store.experiences.length > 0),
|
|
1146
1206
|
'[2 可召回] ' + pass(verified.length > 0) + ` (verified=${verified.length})`,
|
|
1147
1207
|
'[2.5 证据] ' + evidenceLine(turn),
|
|
1148
|
-
'[2.6 衰减] ' + ((turn.candidates || [])
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1208
|
+
'[2.6 衰减] ' + ((turn.candidates || [])
|
|
1209
|
+
.filter((c) => {
|
|
1210
|
+
const record = store.experiences.find((item) => item.id === c.id)
|
|
1211
|
+
return !record || record.status !== 'rejected'
|
|
1212
|
+
})
|
|
1213
|
+
.map((c) => {
|
|
1214
|
+
const record = store.experiences.find((item) => item.id === c.id)
|
|
1215
|
+
return record ? D.decayLine(record, Date.now(), CONFIG) : `${c.id} (已不在库)`
|
|
1216
|
+
}).join(' | ') || '(空)'),
|
|
1152
1217
|
`[3 检索] ${injection ? 'PASS' : 'FAIL'} (top=${(turn.candidates && turn.candidates[0] && turn.candidates[0].score) ?? 0}, 阈值 ${turn.threshold})`,
|
|
1153
1218
|
`[4 注入] ${injection ? (injection.rendered ? 'PASS' : 'FAIL: 已选择但 prompt 未重新读取(refreshContext 未触发)') : 'FAIL: 没有选择任何经验'}`,
|
|
1154
1219
|
`[5 模型可见] 人工核对:回答中是否回显 nonce ${injection ? injection.nonce : '(无)'}`,
|
package/lib/capture.js
CHANGED
|
@@ -198,7 +198,58 @@ export function effectiveSteps(calls) {
|
|
|
198
198
|
return count
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
const ACTION_VERBS = /^(
|
|
201
|
+
const ACTION_VERBS = /^(新增|写|跑通|跑|执行|修改|修复|重构|实现|添加|删除|更新|重命名|移动|替换|补|加|改为)/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Executions worth repeating. A run recipe is "how to invoke this thing", so a
|
|
205
|
+
* bare mkdir / dir listing / file read never counts: those describe discovery,
|
|
206
|
+
* not the recipe.
|
|
207
|
+
*/
|
|
208
|
+
export function runSteps(calls) {
|
|
209
|
+
const kept = []
|
|
210
|
+
for (const call of calls || []) {
|
|
211
|
+
if (!call || !call.ok) continue
|
|
212
|
+
if (classify(call).kind !== 'run') continue
|
|
213
|
+
if (stepPriority(call) < 2) continue
|
|
214
|
+
if (isNoiseStep(stepLabel(call))) continue
|
|
215
|
+
kept.push(call)
|
|
216
|
+
}
|
|
217
|
+
return kept
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The program a run call actually launched: "& \"C:\\...\\node.exe\" x.mjs" -> node. */
|
|
221
|
+
export function commandHeads(calls) {
|
|
222
|
+
const heads = []
|
|
223
|
+
for (const call of calls || []) {
|
|
224
|
+
if (!call || !call.ok) continue
|
|
225
|
+
if (classify(call).kind !== 'run') continue
|
|
226
|
+
if (isNoiseStep(stepLabel(call))) continue
|
|
227
|
+
const raw = firstString(call && (call.hint || call.argsHint || call.args)) || ''
|
|
228
|
+
const lead = raw.trim().replace(/^&\s*/, '')
|
|
229
|
+
const quoted = lead.match(/^("[^"]+"|'[^']+'|\S+)/)
|
|
230
|
+
const program = quoted ? quoted[0].replace(/^["']|["']$/g, '') : ''
|
|
231
|
+
const base = program.split(/[\\/]+/).filter(Boolean).pop() || ''
|
|
232
|
+
const head = base.replace(/\.exe$/i, '').toLowerCase()
|
|
233
|
+
if (head && /^[a-z][a-z0-9_.-]*$/.test(head) && !/^[a-z]:$/.test(head)) heads.push(head)
|
|
234
|
+
}
|
|
235
|
+
return [...new Set(heads)].sort()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The script a run recipe executes, as a bare name: "run build.mjs" -> build. */
|
|
239
|
+
export function runArtifactName(calls) {
|
|
240
|
+
for (const call of calls || []) {
|
|
241
|
+
if (!call || !call.ok) continue
|
|
242
|
+
if (classify(call).kind !== 'run') continue
|
|
243
|
+
const raw = firstString(call && (call.hint || call.argsHint || call.args)) || ''
|
|
244
|
+
const hit = raw.match(/[A-Za-z0-9_][A-Za-z0-9_.-]*\.(?:mjs|cjs|js|py|go|rs|sh|ps1|java)\b/i)
|
|
245
|
+
if (!hit) continue
|
|
246
|
+
const base = hit[0].split(/[\\/]/).pop()
|
|
247
|
+
return base.replace(/\.[^.]+$/, '')
|
|
248
|
+
}
|
|
249
|
+
return ''
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
202
253
|
|
|
203
254
|
/** The object of a title must name a concrete artifact, not only <placeholders>. */
|
|
204
255
|
export function actionableTitle(title) {
|
|
@@ -220,15 +271,19 @@ export function actionableTitle(title) {
|
|
|
220
271
|
* G7 — refuses captures whose steps or title are not a reusable recipe.
|
|
221
272
|
* Runs after the G1..G6 chain; the caller writes trace.skipReason = reason.
|
|
222
273
|
*/
|
|
223
|
-
export function gateStepQuality(trace, record, config) {
|
|
274
|
+
export function gateStepQuality(trace, record, config, intent) {
|
|
224
275
|
const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }
|
|
225
276
|
const calls = (trace && trace.calls) || []
|
|
226
|
-
|
|
277
|
+
// Two scales: a change is judged by its write/test steps, an invocation
|
|
278
|
+
// recipe by its repeatable executions. mkdir/read never enter either count.
|
|
279
|
+
const effective = intent === 'run' ? runSteps(calls).length : effectiveSteps(calls)
|
|
227
280
|
if (effective < cfg.minEffectiveSteps) {
|
|
281
|
+
const what = intent === 'run' ? '可复用执行(建目录/读文件不计)' : '(只有写操作与测试执行算核心步骤)'
|
|
228
282
|
return {
|
|
229
283
|
ok: false,
|
|
230
|
-
reason: `G7: 有效步骤 ${effective} < ${cfg.minEffectiveSteps}
|
|
284
|
+
reason: `G7: 有效步骤 ${effective} < ${cfg.minEffectiveSteps}${intent === 'run' ? ' 次' : ''}${what}`,
|
|
231
285
|
effective,
|
|
286
|
+
runOnly: intent === 'run',
|
|
232
287
|
}
|
|
233
288
|
}
|
|
234
289
|
const title = String((record && record.title) || '')
|
|
@@ -262,6 +317,46 @@ export function summarizeCalls(calls) {
|
|
|
262
317
|
* A second run that only re-reads and re-runs the tests has the same fingerprint
|
|
263
318
|
* as the first run that scaffolded everything — that is the real "same task".
|
|
264
319
|
*/
|
|
320
|
+
const EXT_KIND = {
|
|
321
|
+
code: ['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'go', 'rs', 'java', 'cs', 'rb', 'php', 'sh', 'ps1', 'cmd', 'bat'],
|
|
322
|
+
image: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg', 'tif', 'tiff', 'ico', 'heic'],
|
|
323
|
+
doc: ['md', 'txt', 'pdf', 'docx', 'doc', 'pptx', 'xlsx', 'csv', 'html', 'css', 'json', 'yml', 'yaml'],
|
|
324
|
+
binary: ['zip', 'gz', 'tgz', 'exe', 'dll', 'whl', 'jar'],
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const KIND_BY_EXT = (() => {
|
|
328
|
+
const map = new Map()
|
|
329
|
+
for (const [kind, exts] of Object.entries(EXT_KIND)) for (const ext of exts) map.set(ext, kind)
|
|
330
|
+
return map
|
|
331
|
+
})()
|
|
332
|
+
|
|
333
|
+
export function artifactKindOf(target) {
|
|
334
|
+
const name = String(target || '').toLowerCase()
|
|
335
|
+
const match = name.match(/\.([a-z0-9]+)$/)
|
|
336
|
+
if (!match) return ''
|
|
337
|
+
return KIND_BY_EXT.get(match[1]) || ''
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function artifactKindsOf(targets) {
|
|
341
|
+
const kinds = new Set()
|
|
342
|
+
for (const target of targets || []) {
|
|
343
|
+
const kind = artifactKindOf(target)
|
|
344
|
+
if (kind) kinds.add(kind)
|
|
345
|
+
}
|
|
346
|
+
return [...kinds].sort()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Do these two runs write fundamentally different kinds of artifact?
|
|
351
|
+
* Only says "conflict" when both sides have evidence and the sets are disjoint.
|
|
352
|
+
*/
|
|
353
|
+
export function artifactTypesConflict(left, right) {
|
|
354
|
+
const a = (left && left.fingerprint && left.fingerprint.kinds) || []
|
|
355
|
+
const b = (right && right.fingerprint && right.fingerprint.kinds) || []
|
|
356
|
+
if (!a.length || !b.length) return false
|
|
357
|
+
return !a.some((kind) => b.includes(kind))
|
|
358
|
+
}
|
|
359
|
+
|
|
265
360
|
export function outcomeFingerprint(calls) {
|
|
266
361
|
const list = calls || []
|
|
267
362
|
const artifactOf = (call) => {
|
|
@@ -277,9 +372,12 @@ export function outcomeFingerprint(calls) {
|
|
|
277
372
|
// A run that failed and then went green still ends green; the failure itself is
|
|
278
373
|
// captured as a reverse example, not as a reason to refuse the merge.
|
|
279
374
|
const green = testCalls.length > 0 && testCalls[testCalls.length - 1].ok === true
|
|
280
|
-
const
|
|
375
|
+
const allArtifacts = writes.length ? writes : reads
|
|
376
|
+
const artifacts = allArtifacts.filter(isCode)
|
|
281
377
|
return {
|
|
282
378
|
targets: [...new Set(artifacts)].sort(),
|
|
379
|
+
commands: commandHeads(list),
|
|
380
|
+
kinds: artifactKindsOf(allArtifacts),
|
|
283
381
|
tests: [...new Set(tests)].sort(),
|
|
284
382
|
green,
|
|
285
383
|
verificationOnly: writes.length === 0,
|
|
@@ -291,6 +389,8 @@ export function fingerprintsMatch(left, right) {
|
|
|
291
389
|
const a = left && left.fingerprint
|
|
292
390
|
const b = right && right.fingerprint
|
|
293
391
|
if (!a || !b || !a.green || !b.green) return false
|
|
392
|
+
// Same name, different artifact type is not the same outcome.
|
|
393
|
+
if (artifactTypesConflict(left, right)) return false
|
|
294
394
|
const have = new Set(a.targets || [])
|
|
295
395
|
const want = new Set(b.targets || [])
|
|
296
396
|
if (!have.size || !want.size) return false
|
|
@@ -376,7 +476,7 @@ export function inferTaskType(calls, cwd) {
|
|
|
376
476
|
return 'workspace/general'
|
|
377
477
|
}
|
|
378
478
|
|
|
379
|
-
export function shouldCapture(trace, counts, config) {
|
|
479
|
+
export function shouldCapture(trace, counts, config, intent) {
|
|
380
480
|
const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }
|
|
381
481
|
const calls = (trace && trace.calls) || []
|
|
382
482
|
const successes = calls.filter((call) => call && call.ok)
|
|
@@ -395,7 +495,11 @@ export function shouldCapture(trace, counts, config) {
|
|
|
395
495
|
const tests = successes.filter((call) => isTestCall(call))
|
|
396
496
|
const testFailures = calls.filter((call) => call && !call.ok && isTestCall(call))
|
|
397
497
|
const verifiedGreen = tests.length > 0 && testFailures.length === 0
|
|
398
|
-
|
|
498
|
+
// A run recipe is legitimate evidence on its own: nobody wrote anything, but
|
|
499
|
+
// the execution was repeated and is worth replaying.
|
|
500
|
+
const executions = intent === 'run' ? runSteps(calls) : []
|
|
501
|
+
const runOnly = intent === 'run' && !writes.length && !recoveries.length && !verifiedGreen
|
|
502
|
+
if (!writes.length && !recoveries.length && !verifiedGreen && !(runOnly && executions.length >= 2)) {
|
|
399
503
|
return { ok: false, reason: 'G3: 无成功写操作、无失败后成功,且不是全绿验证轨迹' }
|
|
400
504
|
}
|
|
401
505
|
|
|
@@ -405,7 +509,14 @@ export function shouldCapture(trace, counts, config) {
|
|
|
405
509
|
if ((counts && counts.autoTotal) >= cfg.libraryLimit) {
|
|
406
510
|
return { ok: false, reason: `G5: 自动经验库已达上限 ${cfg.libraryLimit}` }
|
|
407
511
|
}
|
|
408
|
-
return {
|
|
512
|
+
return {
|
|
513
|
+
ok: true,
|
|
514
|
+
writes,
|
|
515
|
+
recoveries,
|
|
516
|
+
runOnly,
|
|
517
|
+
executions: executions.length,
|
|
518
|
+
verificationOnly: !writes.length && !recoveries.length,
|
|
519
|
+
}
|
|
409
520
|
}
|
|
410
521
|
|
|
411
522
|
function verbsOf(taskType) {
|
|
@@ -424,7 +535,12 @@ export function workspaceName(cwd) {
|
|
|
424
535
|
|
|
425
536
|
const GENERIC_DIRS = new Set([
|
|
426
537
|
'users', 'home', 'desktop', 'documents', 'downloads', 'src', 'test', 'tests',
|
|
427
|
-
'lib', 'node_modules', 'bin', 'tmp', 'temp', 'app', 'apps', 'workspace',
|
|
538
|
+
'lib', 'node_modules', 'bin', 'tmp', 'temp', 'app', 'apps', 'workspace',
|
|
539
|
+
'workspaces',
|
|
540
|
+
// Tool-chain directories: every recipe on this machine touches them, so they
|
|
541
|
+
// say nothing about *which project* a record belongs to.
|
|
542
|
+
'node', 'python', 'python3', 'npm', 'pnpm', 'npx', 'yarn', 'git', 'pwsh',
|
|
543
|
+
'deno', 'bun', 'runtime', 'runtimes', 'dependencies', 'dsh', 'windows',
|
|
428
544
|
])
|
|
429
545
|
|
|
430
546
|
/**
|
|
@@ -482,9 +598,14 @@ export function scopeOf(record) {
|
|
|
482
598
|
}
|
|
483
599
|
|
|
484
600
|
/** Two records describe different targets when both name a project dir and the names disagree. */
|
|
601
|
+
/** Scope tokens that actually name a project, not the tool-chain around it. */
|
|
602
|
+
export function projectScope(record) {
|
|
603
|
+
return scopeOf(record).filter((item) => item.length >= 2 && !GENERIC_DIRS.has(item))
|
|
604
|
+
}
|
|
605
|
+
|
|
485
606
|
export function scopeConflicts(existing, draft) {
|
|
486
|
-
const left =
|
|
487
|
-
const right =
|
|
607
|
+
const left = projectScope(existing)
|
|
608
|
+
const right = projectScope(draft)
|
|
488
609
|
if (!left.length || !right.length) return false
|
|
489
610
|
return !left.some((item) => right.includes(item))
|
|
490
611
|
}
|
|
@@ -506,14 +627,29 @@ export function buildCandidate(trace, info, config) {
|
|
|
506
627
|
const writes = calls.filter((call) => call && call.ok && classify(call).kind === 'write')
|
|
507
628
|
const runs = calls.filter((call) => call && call.ok && classify(call).kind === 'run')
|
|
508
629
|
|
|
630
|
+
const runOnly = Boolean(info && info.runOnly) || (!writes.length && runs.length > 0)
|
|
509
631
|
const primary = writes.length ? classify(writes[0]).target : runs.length ? classify(runs[0]).target : ''
|
|
632
|
+
const scriptName = runOnly ? runArtifactName(calls) : ''
|
|
510
633
|
const verb = writes.length ? '写' : '跑通'
|
|
511
634
|
// Prefer a concrete artifact name (raw basename, before path normalization):
|
|
512
635
|
// "<path>/<name>.js" cannot name what was produced, "slugify.js" can.
|
|
513
636
|
const named = writes
|
|
514
637
|
.map((call) => artifactName(call))
|
|
515
638
|
.find((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) && /[A-Za-z0-9]{2,}/.test(name))
|
|
516
|
-
const title = clip(
|
|
639
|
+
const title = clip(
|
|
640
|
+
runOnly
|
|
641
|
+
? scriptName
|
|
642
|
+
? `跑 ${scriptName}`
|
|
643
|
+
: primary
|
|
644
|
+
? `跑 ${primary}`
|
|
645
|
+
: verbsOf(taskType)
|
|
646
|
+
: named
|
|
647
|
+
? `新增 ${named}`
|
|
648
|
+
: primary
|
|
649
|
+
? `${verb} ${primary}`
|
|
650
|
+
: verbsOf(taskType),
|
|
651
|
+
110,
|
|
652
|
+
)
|
|
517
653
|
const place = workspaceName(info && info.cwd)
|
|
518
654
|
const when = clip(place ? `在 ${place} 里 ${verbsOf(taskType)}` : `当需要${verbsOf(taskType)}时`, 160)
|
|
519
655
|
const keywords = [...new Set([taskType, ...steps.map((step) => step.split(' ')[1] || '').filter(Boolean)])]
|
package/lib/decay.js
CHANGED
|
@@ -127,7 +127,7 @@ 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' && !isPinned(record))
|
|
130
|
+
.filter((record) => record && record.status !== 'verified' && record.status !== 'rejected' && !isPinned(record))
|
|
131
131
|
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
132
132
|
.filter((entry) => entry.info.dormant)
|
|
133
133
|
.sort((left, right) => left.info.rank - right.info.rank)
|
|
@@ -178,9 +178,13 @@ export function decayLine(record, now = Date.now(), config = {}) {
|
|
|
178
178
|
|
|
179
179
|
export function renderDecayReport(store, now = Date.now(), config = {}) {
|
|
180
180
|
const records = Array.isArray(store && store.experiences) ? store.experiences : []
|
|
181
|
-
|
|
181
|
+
// Rejected records are kept as an audit trail: they cannot be recalled, merged
|
|
182
|
+
// or evicted, so counting them would only distort the capacity numbers.
|
|
183
|
+
const hiddenRejected = records.filter((record) => record && record.status === 'rejected').length
|
|
184
|
+
const live = records.filter((record) => record && record.status !== 'rejected')
|
|
185
|
+
const proposal = selectEvictions(live, now, config)
|
|
182
186
|
const counts = { active: 0, dormant: 0, pinned: 0 }
|
|
183
|
-
const sorted =
|
|
187
|
+
const sorted = live
|
|
184
188
|
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
185
189
|
.sort((left, right) => {
|
|
186
190
|
if (left.info.pinned !== right.info.pinned) return left.info.pinned ? 1 : -1
|
|
@@ -193,7 +197,8 @@ export function renderDecayReport(store, now = Date.now(), config = {}) {
|
|
|
193
197
|
const eligible = new Set(proposal.evictions.map((item) => item.id))
|
|
194
198
|
const top = sorted.filter((entry) => !entry.info.pinned).slice(0, 5)
|
|
195
199
|
const lines = [
|
|
196
|
-
`[M3.3 衰减报告] 库 ${
|
|
200
|
+
`[M3.3 衰减报告] 库 ${live.length} 条 · active ${counts.active} / dormant ${counts.dormant} / pinned ${counts.pinned}`,
|
|
201
|
+
...(hiddenRejected ? [`已排除 ${hiddenRejected} 条 rejected(审计留档,不参与排序与容量;\/memory list --all 查看)`] : []),
|
|
197
202
|
`容量: ${proposal.count}/${proposal.libraryLimit}(阈值 ${proposal.threshold} · 比例 ${num(config.capacityRatio, DECAY_DEFAULTS.capacityRatio)})· ${proposal.triggered ? '已达阈值' : '未达阈值'}`,
|
|
198
203
|
proposal.triggered
|
|
199
204
|
? `建议归档 ${proposal.evictions.length} 条(最多 ${num(config.evictBatch, DECAY_DEFAULTS.evictBatch)} 条/次,按 rank 升序):`
|
package/lib/recall.js
CHANGED
|
@@ -110,6 +110,31 @@ const round = (value) => Math.round(value * 1000) / 1000
|
|
|
110
110
|
* "MODULE_NOT_FOUND") discriminate; common ones ("报错", "模块") must not.
|
|
111
111
|
* Without this, a noisy task description lifts every record at once.
|
|
112
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* Which kind of task is this: running something, or writing something?
|
|
115
|
+
* Empty means "cannot tell" and never vetoes anything.
|
|
116
|
+
*/
|
|
117
|
+
export function intentOf(experienceOrText) {
|
|
118
|
+
const text =
|
|
119
|
+
typeof experienceOrText === 'string' ? experienceOrText : profileText(experienceOrText)
|
|
120
|
+
const found = features(text)
|
|
121
|
+
const run = found.has('#run')
|
|
122
|
+
const write = found.has('#write')
|
|
123
|
+
if (run && !write) return 'run'
|
|
124
|
+
if (write && !run) return 'write'
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* "run the image pipeline" and "add a helper function" are different tasks even
|
|
130
|
+
* when they share every noun; only verdicts with evidence on both sides count.
|
|
131
|
+
*/
|
|
132
|
+
export function intentConflict(left, right) {
|
|
133
|
+
const a = intentOf(left)
|
|
134
|
+
const b = intentOf(right)
|
|
135
|
+
return Boolean(a && b && a !== b)
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
export function corpus(experiences) {
|
|
114
139
|
const df = new Map()
|
|
115
140
|
let n = 0
|
package/package.json
CHANGED
package/tools/e2e.mjs
CHANGED
|
@@ -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) => {
|
package/tools/selftest.mjs
CHANGED
|
@@ -1025,6 +1025,165 @@ test('T71 a corrupt store degrades instead of throwing', () => {
|
|
|
1025
1025
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
1026
1026
|
})
|
|
1027
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
|
+
|
|
1028
1187
|
// ---------------------------------------------------------------- summary
|
|
1029
1188
|
|
|
1030
1189
|
const total = pass + failures.length
|