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/CHANGELOG.md +185 -0
- package/README.md +12 -4
- package/index.js +140 -90
- package/lib/capture.js +149 -12
- package/lib/decay.js +9 -4
- package/lib/recall.js +220 -0
- package/lib/store.js +66 -0
- package/package.json +1 -1
- package/tools/e2e.mjs +89 -1
- package/tools/selftest.mjs +272 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,190 @@
|
|
|
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
|
+
|
|
122
|
+
## [1.0.8] — 2026-11-09 — CI 测试与持久化回归
|
|
123
|
+
|
|
124
|
+
### 新增
|
|
125
|
+
|
|
126
|
+
- `.github/workflows/test.yml`:每次 push 到 `main` 与每个 pull request 都跑
|
|
127
|
+
`node tools/selftest.mjs` 与 `node tools/e2e.mjs`,矩阵覆盖 Ubuntu/Windows × Node 20/22。
|
|
128
|
+
此前仓库只有「打 tag 时发布 npm」的流水线,代码从不经过测试。
|
|
129
|
+
- `lib/store.js`:把 store 读写从 `index.js` 抽出来,成为可测试的纯模块
|
|
130
|
+
(`emptyStore / normalizeStore / readStore / writeStore`,写入走临时文件 + rename)。
|
|
131
|
+
- `T69`:写入 → 重载 → 断言 `archive` / `evictions` / `nextId` 全部还在。
|
|
132
|
+
- `T70`:未知字段(未来版本新增的表)在旧版本 reload 时不被丢弃。
|
|
133
|
+
- `T71`:损坏 JSON 返回 `{error, store}` 而不是抛异常;缺失文件标记 `missing`。
|
|
134
|
+
|
|
135
|
+
### 修复
|
|
136
|
+
|
|
137
|
+
- `loadStore()` 不再自己重建字段,改走 `normalizeStore()`——这正是 1.0.7 里
|
|
138
|
+
`archive` / `evictions` 被静默抹掉的根因。
|
|
139
|
+
- `e2e` T46 原先断言 `store.archive === undefined`,等于把上面那个 bug 写进了测试;
|
|
140
|
+
改为断言「归档条数为 0」。
|
|
141
|
+
|
|
142
|
+
### 测试
|
|
143
|
+
|
|
144
|
+
`selftest 81 pass / 0 fail`、`e2e 19 pass / 0 fail`(合计 100)。
|
|
145
|
+
|
|
146
|
+
## [1.0.7] — 2026-11-09 — 检索:把「坑」纳入档案,修流水账挤掉垂直经验
|
|
147
|
+
|
|
148
|
+
现象:真实任务(plugin-test-5 写 .mjs、require 不存在的模块、报错后排查修复)
|
|
149
|
+
检索 PASS 且 top=0.26,但注入的是 exp-23/24(写文件流水账),exp-26(node.exe / MODULE_NOT_FOUND 避坑指南)
|
|
150
|
+
连前五都没进。
|
|
151
|
+
|
|
152
|
+
根因两条:
|
|
153
|
+
|
|
154
|
+
1. **打分只看 title/when/keywords,不看 pitfalls/reverse_examples**。经验真正的价值在坑里
|
|
155
|
+
(`MODULE_NOT_FOUND`、`CommandNotFoundException`),这些词根本没进索引。
|
|
156
|
+
2. **特征桶权重太平均**。`#error` 和 `#node` 与 `#file` 同权,一句「报错」压不过一堆「文件/新增」。
|
|
157
|
+
|
|
158
|
+
修法:
|
|
159
|
+
|
|
160
|
+
- `profileText()` 把 `pitfalls` 与 `reverse_examples` 一起入索引。
|
|
161
|
+
- 特征桶分级权重:`#error` 3、`#node` 2.6、`#module` 2.2、`#shell`/`#debug` 2、`#run`/`#write` 1.8、其余 1.4/1.2。
|
|
162
|
+
- 关键特征(`#error`/`#node`/`#module`)同时命中时再给 0.08/个 的确定性加分。
|
|
163
|
+
- 新增工具 `experience_archive`:把记录移入 `store.archive`(可 `/memory restore` 恢复,写 `evictions` 审计),
|
|
164
|
+
用于清理已批准的流水账;不删除数据。
|
|
165
|
+
|
|
166
|
+
实测(真实库,清理 exp-22..25 后剩下 4 条):四条真实失败查询上 exp-26 全部排第 1
|
|
167
|
+
(0.364 / 0.373 / 0.502 / 0.502),无关查询仍为 0。测试:selftest 78 pass、e2e 19 pass。
|
|
168
|
+
|
|
169
|
+
## [1.0.6] — 2026-11-09 — 检索:停用词、特征桶与 idf 加权
|
|
170
|
+
|
|
171
|
+
现象:一条噪声较大的任务描述("跑一个会踩坑的 .mjs 脚本…内置 node.exe 模块报错")
|
|
172
|
+
在旧打分下只有 0.07,exp-26 根本没被召回。
|
|
173
|
+
|
|
174
|
+
修法:
|
|
175
|
+
|
|
176
|
+
- 停用词扩到口语填充词(随便/故意/场景/试试…),噪声不再稀释覆盖率。
|
|
177
|
+
- 新增特征桶(`#node` `#shell` `#error` `#module` `#test` `#file` `#path` `#run` `#write` `#debug` …),
|
|
178
|
+
在原始文本上判定,词表不重合也能命中;`#run`/`#write` 区分"跑脚本"与"加函数"这两类意图。
|
|
179
|
+
- 打分改为 idf 加权:跨经验常见的词(报错/模块/脚本)权重低,稀有词(node.exe/MODULE_NOT_FOUND)权重高;
|
|
180
|
+
中文 bigram 权重 ×0.45(它们最容易误撞)。
|
|
181
|
+
- 索引文件名 basename,"truncate.test.mjs" 也能匹配 "test/truncate.test.mjs"。
|
|
182
|
+
- 阈值可配:`/memory config set recallThreshold 0.1`(默认仍 0.15;实测无关查询得分为 0,
|
|
183
|
+
噪声查询下第二名 0.143,降到 0.05 会把第二名一起放进来)。
|
|
184
|
+
|
|
185
|
+
实测(真实库 7 条):噪声查询 exp-26=0.159 排第一;"新增纯函数+用例" exp-1=0.852;
|
|
186
|
+
"重命名图片" 全部 0。测试 77 pass(selftest)/ 19 pass(e2e)。
|
|
187
|
+
|
|
3
188
|
## [1.0.5] — 2026-11-09 — 第一个 npm 版本
|
|
4
189
|
|
|
5
190
|
- 发布到 npm:`dsh-experience@1.0.4` 是首个公开版本,`1.0.5` 修正安装说明。
|
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
|
|
|
@@ -122,18 +122,26 @@ plugin_manager install_bundle target=link:<PATH-TO-dsh-experience>
|
|
|
122
122
|
- 注入前做长度截断(默认 2 条 / 1600 字符);
|
|
123
123
|
- 仓库不包含任何作者运行时数据,骨架见 [store.example.json](store.example.json)。
|
|
124
124
|
|
|
125
|
+
## 检索加权
|
|
126
|
+
|
|
127
|
+
当任务包含报错(`#error`)或明确排错意图时,含有避坑经验的历史条目将获得 3 倍以上的检索加权,确保模型优先复用排错捷径。
|
|
128
|
+
|
|
129
|
+
实现上:`pitfalls` 与 `reverse_examples` 一并入索引,特征桶分级加权(`#error` 3、`#node` 2.6、`#module` 2.2…),
|
|
130
|
+
`#error`/`#node`/`#module` 同时命中再加确定性加分。实测同一批失败查询上,正确条目的分数从 0.07 提升到 0.36–0.50,
|
|
131
|
+
并在清理流水账后稳定排第 1。
|
|
132
|
+
|
|
125
133
|
## 已知限制
|
|
126
134
|
|
|
127
135
|
- 召回仅在每轮第 1 步生效;
|
|
128
136
|
- `auto-verified` 默认不参与召回 —— 错误经验的代价高于多一次点击;
|
|
129
|
-
-
|
|
137
|
+
- 相似度是词覆盖打分 + 特征桶,不做向量检索(零依赖的代价);**同义改写可能漏召回**——记录里写 `MODULE_NOT_FOUND`、提问只说「模块找不到」时不一定匹配,这需要向量检索才能解决,属能力边界;
|
|
130
138
|
- 淘汰只归档不删除,需要 `restore` 才能回到召回池;
|
|
131
139
|
- 经验只描述「怎么做」,不包含业务代码本身。
|
|
132
140
|
|
|
133
141
|
## 开发
|
|
134
142
|
|
|
135
143
|
```bash
|
|
136
|
-
node tools/selftest.mjs #
|
|
144
|
+
node tools/selftest.mjs # 91 pass / 0 fail(纯函数 + 闸门 + 衰减)
|
|
137
145
|
node tools/e2e.mjs # 19 pass / 0 fail(假 Cordis context 驱动真实 apply())
|
|
138
146
|
```
|
|
139
147
|
|
package/index.js
CHANGED
|
@@ -19,6 +19,8 @@ import crypto from 'node:crypto'
|
|
|
19
19
|
import * as C from './lib/capture.js'
|
|
20
20
|
import * as E from './lib/evidence.js'
|
|
21
21
|
import * as D from './lib/decay.js'
|
|
22
|
+
import * as R from './lib/recall.js'
|
|
23
|
+
import * as S from './lib/store.js'
|
|
22
24
|
import { renderLookup } from './lib/lookup.js'
|
|
23
25
|
|
|
24
26
|
export const name = 'dsh-experience'
|
|
@@ -33,25 +35,10 @@ const PROBE_FILE = path.join(STORE_DIR, 'probe.json')
|
|
|
33
35
|
const THRESHOLD = 0.15
|
|
34
36
|
const MAX_EXPERIENCES = 2
|
|
35
37
|
const MAX_CHARS = 1600
|
|
36
|
-
const BUILD = '
|
|
38
|
+
const BUILD = 'r20-scope-tools'
|
|
37
39
|
|
|
38
40
|
let CONFIG = { ...C.DEFAULT_CONFIG }
|
|
39
41
|
|
|
40
|
-
const STOP = new Set([
|
|
41
|
-
'的', '了', '和', '与', '在', '是', '把', '并', '就', '还', '也', '有', '用', '要', '中',
|
|
42
|
-
'the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'is', 'are', 'be',
|
|
43
|
-
'it', 'this', 'that', 'then', 'use', 'run', 'can',
|
|
44
|
-
])
|
|
45
|
-
|
|
46
|
-
const emptyStore = () => ({
|
|
47
|
-
version: 1,
|
|
48
|
-
nextId: 1,
|
|
49
|
-
nextInjectionId: 1,
|
|
50
|
-
experiences: [],
|
|
51
|
-
injections: [],
|
|
52
|
-
turns: [],
|
|
53
|
-
})
|
|
54
|
-
|
|
55
42
|
function loadConfig() {
|
|
56
43
|
try {
|
|
57
44
|
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))
|
|
@@ -83,77 +70,56 @@ function saveConfig() {
|
|
|
83
70
|
}
|
|
84
71
|
|
|
85
72
|
function loadStore() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const parsed = JSON.parse(raw)
|
|
89
|
-
if (!parsed || typeof parsed !== 'object') throw new Error('not an object')
|
|
90
|
-
const store = {
|
|
91
|
-
version: Math.max(2, Number(parsed.version || 1)),
|
|
92
|
-
nextId: parsed.nextId || 1,
|
|
93
|
-
nextInjectionId: parsed.nextInjectionId || 1,
|
|
94
|
-
experiences: Array.isArray(parsed.experiences) ? parsed.experiences : [],
|
|
95
|
-
injections: Array.isArray(parsed.injections) ? parsed.injections : [],
|
|
96
|
-
turns: Array.isArray(parsed.turns) ? parsed.turns : [],
|
|
97
|
-
}
|
|
98
|
-
E.migrateStore(store)
|
|
99
|
-
// Records written before the redaction fix may still carry absolute paths.
|
|
100
|
-
let scrubbed = 0
|
|
101
|
-
for (const record of store.experiences) if (C.scrubRecord(record)) scrubbed += 1
|
|
102
|
-
if (scrubbed) console.warn(`[experience] scrubbed ${scrubbed} legacy record(s)`)
|
|
103
|
-
return store
|
|
104
|
-
} catch (error) {
|
|
105
|
-
if (error && error.code === 'ENOENT') return emptyStore()
|
|
73
|
+
const result = S.readStore(STORE_FILE)
|
|
74
|
+
if (result.error) {
|
|
106
75
|
try {
|
|
107
76
|
fs.renameSync(STORE_FILE, `${STORE_FILE}.broken-${Date.now()}`)
|
|
108
77
|
} catch {}
|
|
109
|
-
console.warn('[experience] store unreadable, started empty:', error && error.message)
|
|
110
|
-
|
|
78
|
+
console.warn('[experience] store unreadable, started empty:', result.error && result.error.message)
|
|
79
|
+
}
|
|
80
|
+
const store = result.store
|
|
81
|
+
E.migrateStore(store)
|
|
82
|
+
// Records written before the redaction fix may still carry absolute paths.
|
|
83
|
+
let scrubbed = 0
|
|
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
|
+
}
|
|
111
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
|
+
}
|
|
102
|
+
if (scrubbed) console.warn(`[experience] scrubbed ${scrubbed} legacy record(s)`)
|
|
103
|
+
return store
|
|
112
104
|
}
|
|
113
105
|
|
|
114
106
|
function saveStore(store) {
|
|
115
|
-
|
|
116
|
-
const tmp = `${STORE_FILE}.tmp`
|
|
117
|
-
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8')
|
|
118
|
-
fs.renameSync(tmp, STORE_FILE)
|
|
107
|
+
S.writeStore(STORE_FILE, store)
|
|
119
108
|
}
|
|
120
109
|
|
|
121
110
|
function tokens(text) {
|
|
122
|
-
|
|
123
|
-
const source = String(text || '').toLowerCase()
|
|
124
|
-
for (const match of source.matchAll(/[a-z0-9_][a-z0-9_.\-/]*/g)) {
|
|
125
|
-
const token = match[0]
|
|
126
|
-
if (token.length >= 2 && !STOP.has(token)) out.add(token)
|
|
127
|
-
}
|
|
128
|
-
const cjk = source.replace(/[^\u4e00-\u9fff]+/g, ' ')
|
|
129
|
-
for (const chunk of cjk.split(' ')) {
|
|
130
|
-
for (let i = 0; i + 1 < chunk.length; i++) {
|
|
131
|
-
const bigram = chunk.slice(i, i + 2)
|
|
132
|
-
if (!STOP.has(bigram)) out.add(bigram)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return out
|
|
111
|
+
return R.expand(text)
|
|
136
112
|
}
|
|
137
113
|
|
|
138
|
-
function
|
|
139
|
-
return
|
|
140
|
-
experience.title,
|
|
141
|
-
experience.taskType,
|
|
142
|
-
experience.when,
|
|
143
|
-
(experience.keywords || []).join(' '),
|
|
144
|
-
].join(' '))
|
|
114
|
+
function scoreExperience(experience, queryTokens, index) {
|
|
115
|
+
return R.score(R.profileText(experience), queryTokens, index)
|
|
145
116
|
}
|
|
146
117
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
let expHits = 0
|
|
153
|
-
for (const token of expTokens) if (queryTokens.has(token)) expHits++
|
|
154
|
-
const queryCoverage = queryHits / queryTokens.size
|
|
155
|
-
const expCoverage = expHits / expTokens.size
|
|
156
|
-
return Math.round((0.7 * queryCoverage + 0.3 * expCoverage) * 1000) / 1000
|
|
118
|
+
// Retrieval floor. Configurable via "/memory config set recallThreshold 0.1"
|
|
119
|
+
// because the right value depends on how noisy the task descriptions are.
|
|
120
|
+
function threshold() {
|
|
121
|
+
const value = Number(CONFIG.recallThreshold)
|
|
122
|
+
return Number.isFinite(value) && value > 0 ? value : THRESHOLD
|
|
157
123
|
}
|
|
158
124
|
|
|
159
125
|
function textOfMessage(message) {
|
|
@@ -321,6 +287,7 @@ export function apply(ctx) {
|
|
|
321
287
|
|
|
322
288
|
const queryText = (payload.messages || []).map(textOfMessage).join('\n')
|
|
323
289
|
const queryTokens = tokens(queryText)
|
|
290
|
+
const index = R.corpus(store.experiences)
|
|
324
291
|
const scored = store.experiences
|
|
325
292
|
.map((experience) => ({ experience, score: scoreExperience(experience, queryTokens) }))
|
|
326
293
|
.sort((a, b) => b.score - a.score)
|
|
@@ -329,10 +296,10 @@ export function apply(ctx) {
|
|
|
329
296
|
title: experience.title,
|
|
330
297
|
status: experience.status,
|
|
331
298
|
score,
|
|
332
|
-
passed: recallable(experience) && score >=
|
|
299
|
+
passed: recallable(experience) && score >= threshold(),
|
|
333
300
|
}))
|
|
334
301
|
const chosen = scored
|
|
335
|
-
.filter((entry) => recallable(entry.experience) && entry.score >=
|
|
302
|
+
.filter((entry) => recallable(entry.experience) && entry.score >= threshold())
|
|
336
303
|
.slice(0, MAX_EXPERIENCES)
|
|
337
304
|
|
|
338
305
|
const trace = {
|
|
@@ -344,7 +311,7 @@ export function apply(ctx) {
|
|
|
344
311
|
query: queryText.slice(0, 400),
|
|
345
312
|
queryTokens: [...queryTokens].slice(0, 40),
|
|
346
313
|
candidates,
|
|
347
|
-
threshold:
|
|
314
|
+
threshold: threshold(),
|
|
348
315
|
injected: chosen.map((entry) => entry.experience.id),
|
|
349
316
|
injectionId: null,
|
|
350
317
|
scores: Object.fromEntries(chosen.map((entry) => [entry.experience.id, entry.score])),
|
|
@@ -553,10 +520,11 @@ export function apply(ctx) {
|
|
|
553
520
|
function noteGatedFailure(trace) {
|
|
554
521
|
try {
|
|
555
522
|
const queryTokens = tokens(trace.query || '')
|
|
523
|
+
const index = R.corpus(store.experiences)
|
|
556
524
|
let best = null
|
|
557
525
|
for (const existing of store.experiences) {
|
|
558
526
|
if (!['verified', 'auto-verified', 'candidate'].includes(existing.status)) continue
|
|
559
|
-
const score = scoreExperience(existing, queryTokens)
|
|
527
|
+
const score = scoreExperience(existing, queryTokens, index)
|
|
560
528
|
if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
|
|
561
529
|
}
|
|
562
530
|
if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(trace.calls || []))
|
|
@@ -575,7 +543,8 @@ export function apply(ctx) {
|
|
|
575
543
|
(item) => item.source && item.source.auto && String(item.source.at || '').slice(0, 10) === today,
|
|
576
544
|
).length
|
|
577
545
|
|
|
578
|
-
const
|
|
546
|
+
const intent = R.intentOf(trace.query || '')
|
|
547
|
+
const gate = C.shouldCapture(trace, { todayAdded, autoTotal }, CONFIG, intent)
|
|
579
548
|
if (!gate.ok) {
|
|
580
549
|
outcome.reason = gate.reason
|
|
581
550
|
if (String(gate.reason).startsWith('G3')) noteGatedFailure(trace)
|
|
@@ -586,7 +555,13 @@ export function apply(ctx) {
|
|
|
586
555
|
|
|
587
556
|
const draft = C.buildCandidate(
|
|
588
557
|
trace,
|
|
589
|
-
{
|
|
558
|
+
{
|
|
559
|
+
cwd: workspaceOf(agent),
|
|
560
|
+
sessionId: trace.sessionId,
|
|
561
|
+
agentId: trace.sessionId,
|
|
562
|
+
turn: trace.turn,
|
|
563
|
+
runOnly: Boolean(gate.runOnly),
|
|
564
|
+
},
|
|
590
565
|
CONFIG,
|
|
591
566
|
)
|
|
592
567
|
|
|
@@ -603,7 +578,7 @@ export function apply(ctx) {
|
|
|
603
578
|
}
|
|
604
579
|
|
|
605
580
|
// G7: the capture has to read as a recipe, not as shell transcript fragments.
|
|
606
|
-
const quality = C.gateStepQuality(trace, draft, CONFIG)
|
|
581
|
+
const quality = C.gateStepQuality(trace, draft, CONFIG, intent)
|
|
607
582
|
if (!quality.ok) {
|
|
608
583
|
trace.skipReason = quality.reason
|
|
609
584
|
outcome.reason = quality.reason
|
|
@@ -622,14 +597,25 @@ export function apply(ctx) {
|
|
|
622
597
|
}
|
|
623
598
|
|
|
624
599
|
const queryTokens = tokens([draft.title, draft.taskType, draft.when, (draft.keywords || []).join(' ')].join(' '))
|
|
600
|
+
const index = R.corpus(store.experiences)
|
|
625
601
|
const sameScope = (record) => !CONFIG.scopeMustMatch || !C.scopeConflicts(record, draft)
|
|
626
602
|
const mergeable = ['verified', 'auto-verified', 'candidate']
|
|
627
603
|
const runSteps = C.summarizeCalls(trace.calls || [])
|
|
628
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
|
+
|
|
629
614
|
let fpBest = null
|
|
630
615
|
for (const existing of store.experiences) {
|
|
631
616
|
if (!mergeable.includes(existing.status)) continue
|
|
632
617
|
if (!sameScope(existing)) continue
|
|
618
|
+
if (intentBlocked(existing)) continue
|
|
633
619
|
if (!C.fingerprintsMatch(existing, draft)) continue
|
|
634
620
|
if (!fpBest) fpBest = existing
|
|
635
621
|
}
|
|
@@ -639,13 +625,28 @@ export function apply(ctx) {
|
|
|
639
625
|
if (!mergeable.includes(existing.status)) continue
|
|
640
626
|
if (!sameScope(existing)) continue
|
|
641
627
|
if (existing.taskType && draft.taskType && existing.taskType !== draft.taskType) continue
|
|
642
|
-
|
|
628
|
+
if (intentBlocked(existing)) continue
|
|
629
|
+
const score = scoreExperience(existing, queryTokens, index)
|
|
643
630
|
if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
|
|
644
631
|
}
|
|
645
632
|
if (best && CONFIG.scopeMustMatch && C.scopeConflicts(best.existing, draft)) best = null
|
|
646
633
|
|
|
647
634
|
// A fingerprint match is the stronger evidence: same artifact, green tests.
|
|
648
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
|
+
|
|
649
650
|
const target = fpBest
|
|
650
651
|
? { existing: fpBest, score: 0, byFingerprint: true }
|
|
651
652
|
: best
|
|
@@ -674,7 +675,7 @@ export function apply(ctx) {
|
|
|
674
675
|
noteEvidence(created, trace, 'success', Boolean(draft.fingerprint && draft.fingerprint.green), runSteps)
|
|
675
676
|
outcome.ok = true
|
|
676
677
|
outcome.id = id
|
|
677
|
-
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})` : ''})`
|
|
678
679
|
outcome.ms = Math.round(performance.now() - started)
|
|
679
680
|
persist()
|
|
680
681
|
return outcome
|
|
@@ -830,6 +831,39 @@ export function apply(ctx) {
|
|
|
830
831
|
},
|
|
831
832
|
})
|
|
832
833
|
|
|
834
|
+
ctx.tools.register({
|
|
835
|
+
name: 'experience_archive',
|
|
836
|
+
description:
|
|
837
|
+
'Move an experience out of the active library into store.archive (reversible with /memory restore <id>). ' +
|
|
838
|
+
'Use for records a human explicitly asked to remove; it never deletes data.',
|
|
839
|
+
parameters: {
|
|
840
|
+
id: { type: 'string', required: true, description: 'Experience id, e.g. exp-22' },
|
|
841
|
+
reason: { type: 'string', description: 'Why this record is being archived' },
|
|
842
|
+
},
|
|
843
|
+
output: {
|
|
844
|
+
schema: { type: 'string' },
|
|
845
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
846
|
+
},
|
|
847
|
+
async execute(args) {
|
|
848
|
+
const index = store.experiences.findIndex((item) => item.id === args.id)
|
|
849
|
+
if (index < 0) return `未找到 ${args.id}`
|
|
850
|
+
const [target] = store.experiences.splice(index, 1)
|
|
851
|
+
store.archive = store.archive || []
|
|
852
|
+
store.archive.push({ ...target, archivedAt: new Date().toISOString() })
|
|
853
|
+
store.evictions = store.evictions || []
|
|
854
|
+
store.evictions.push({
|
|
855
|
+
id: target.id,
|
|
856
|
+
at: new Date().toISOString(),
|
|
857
|
+
by: 'human-request',
|
|
858
|
+
reason: String(args.reason || '人工要求移出活动库'),
|
|
859
|
+
rank: null,
|
|
860
|
+
ageDays: null,
|
|
861
|
+
})
|
|
862
|
+
persist()
|
|
863
|
+
return `已归档 ${target.id}(${target.title});活动库剩 ${store.experiences.length} 条,可用 /memory restore ${target.id} 恢复。`
|
|
864
|
+
},
|
|
865
|
+
})
|
|
866
|
+
|
|
833
867
|
ctx.tools.register({
|
|
834
868
|
name: 'experience_review',
|
|
835
869
|
description:
|
|
@@ -867,7 +901,7 @@ export function apply(ctx) {
|
|
|
867
901
|
const head = [
|
|
868
902
|
`store: ${STORE_FILE}`,
|
|
869
903
|
`build: ${BUILD} config: ${JSON.stringify(CONFIG)}`,
|
|
870
|
-
`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})`,
|
|
871
905
|
`injections: ${store.injections.length} turns: ${store.turns.length} captures: ${captures.length} avgCaptureMs: ${avgMs}`,
|
|
872
906
|
`lastInjection: ${lastInjection ? `${lastInjection.id} nonce=${lastInjection.nonce} rendered=${lastInjection.rendered} tokens=${lastInjection.tokens} ids=${lastInjection.experienceIds.join(',')}` : '-'}`,
|
|
873
907
|
'',
|
|
@@ -958,9 +992,20 @@ export function apply(ctx) {
|
|
|
958
992
|
switch (verb) {
|
|
959
993
|
case '':
|
|
960
994
|
case 'list': {
|
|
961
|
-
|
|
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` : ''
|
|
962
1006
|
return ok(
|
|
963
|
-
|
|
1007
|
+
header +
|
|
1008
|
+
visible
|
|
964
1009
|
.map((item) => {
|
|
965
1010
|
const origin = item.source && item.source.auto ? 'auto' : 'manual'
|
|
966
1011
|
const count = (item.reverse_examples || item.pitfalls || []).length
|
|
@@ -1079,7 +1124,7 @@ export function apply(ctx) {
|
|
|
1079
1124
|
const lines = [
|
|
1080
1125
|
`store: ${STORE_FILE}`,
|
|
1081
1126
|
`build: ${BUILD} config: ${JSON.stringify(CONFIG)}`,
|
|
1082
|
-
`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})`,
|
|
1083
1128
|
`injections: ${store.injections.length} turns: ${store.turns.length} captures: ${captures.length} avgCaptureMs: ${avgMs}`,
|
|
1084
1129
|
`lastInjection: ${lastInjection ? `${lastInjection.id} nonce=${lastInjection.nonce} rendered=${lastInjection.rendered} tokens=${lastInjection.tokens} ids=${lastInjection.experienceIds.join(',')}` : '-'}`,
|
|
1085
1130
|
'',
|
|
@@ -1160,10 +1205,15 @@ export function apply(ctx) {
|
|
|
1160
1205
|
'[1 存储] ' + pass(store.experiences.length > 0),
|
|
1161
1206
|
'[2 可召回] ' + pass(verified.length > 0) + ` (verified=${verified.length})`,
|
|
1162
1207
|
'[2.5 证据] ' + evidenceLine(turn),
|
|
1163
|
-
'[2.6 衰减] ' + ((turn.candidates || [])
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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(' | ') || '(空)'),
|
|
1167
1217
|
`[3 检索] ${injection ? 'PASS' : 'FAIL'} (top=${(turn.candidates && turn.candidates[0] && turn.candidates[0].score) ?? 0}, 阈值 ${turn.threshold})`,
|
|
1168
1218
|
`[4 注入] ${injection ? (injection.rendered ? 'PASS' : 'FAIL: 已选择但 prompt 未重新读取(refreshContext 未触发)') : 'FAIL: 没有选择任何经验'}`,
|
|
1169
1219
|
`[5 模型可见] 人工核对:回答中是否回显 nonce ${injection ? injection.nonce : '(无)'}`,
|