dsh-experience 1.0.4 → 1.0.8
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 +72 -0
- package/README.md +18 -23
- package/index.js +63 -78
- package/lib/capture.js +1 -0
- package/lib/recall.js +195 -0
- package/lib/store.js +66 -0
- package/package.json +1 -1
- package/tools/e2e.mjs +1 -1
- package/tools/selftest.mjs +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.8] — 2026-11-09 — CI 测试与持久化回归
|
|
4
|
+
|
|
5
|
+
### 新增
|
|
6
|
+
|
|
7
|
+
- `.github/workflows/test.yml`:每次 push 到 `main` 与每个 pull request 都跑
|
|
8
|
+
`node tools/selftest.mjs` 与 `node tools/e2e.mjs`,矩阵覆盖 Ubuntu/Windows × Node 20/22。
|
|
9
|
+
此前仓库只有「打 tag 时发布 npm」的流水线,代码从不经过测试。
|
|
10
|
+
- `lib/store.js`:把 store 读写从 `index.js` 抽出来,成为可测试的纯模块
|
|
11
|
+
(`emptyStore / normalizeStore / readStore / writeStore`,写入走临时文件 + rename)。
|
|
12
|
+
- `T69`:写入 → 重载 → 断言 `archive` / `evictions` / `nextId` 全部还在。
|
|
13
|
+
- `T70`:未知字段(未来版本新增的表)在旧版本 reload 时不被丢弃。
|
|
14
|
+
- `T71`:损坏 JSON 返回 `{error, store}` 而不是抛异常;缺失文件标记 `missing`。
|
|
15
|
+
|
|
16
|
+
### 修复
|
|
17
|
+
|
|
18
|
+
- `loadStore()` 不再自己重建字段,改走 `normalizeStore()`——这正是 1.0.7 里
|
|
19
|
+
`archive` / `evictions` 被静默抹掉的根因。
|
|
20
|
+
- `e2e` T46 原先断言 `store.archive === undefined`,等于把上面那个 bug 写进了测试;
|
|
21
|
+
改为断言「归档条数为 0」。
|
|
22
|
+
|
|
23
|
+
### 测试
|
|
24
|
+
|
|
25
|
+
`selftest 81 pass / 0 fail`、`e2e 19 pass / 0 fail`(合计 100)。
|
|
26
|
+
|
|
27
|
+
## [1.0.7] — 2026-11-09 — 检索:把「坑」纳入档案,修流水账挤掉垂直经验
|
|
28
|
+
|
|
29
|
+
现象:真实任务(plugin-test-5 写 .mjs、require 不存在的模块、报错后排查修复)
|
|
30
|
+
检索 PASS 且 top=0.26,但注入的是 exp-23/24(写文件流水账),exp-26(node.exe / MODULE_NOT_FOUND 避坑指南)
|
|
31
|
+
连前五都没进。
|
|
32
|
+
|
|
33
|
+
根因两条:
|
|
34
|
+
|
|
35
|
+
1. **打分只看 title/when/keywords,不看 pitfalls/reverse_examples**。经验真正的价值在坑里
|
|
36
|
+
(`MODULE_NOT_FOUND`、`CommandNotFoundException`),这些词根本没进索引。
|
|
37
|
+
2. **特征桶权重太平均**。`#error` 和 `#node` 与 `#file` 同权,一句「报错」压不过一堆「文件/新增」。
|
|
38
|
+
|
|
39
|
+
修法:
|
|
40
|
+
|
|
41
|
+
- `profileText()` 把 `pitfalls` 与 `reverse_examples` 一起入索引。
|
|
42
|
+
- 特征桶分级权重:`#error` 3、`#node` 2.6、`#module` 2.2、`#shell`/`#debug` 2、`#run`/`#write` 1.8、其余 1.4/1.2。
|
|
43
|
+
- 关键特征(`#error`/`#node`/`#module`)同时命中时再给 0.08/个 的确定性加分。
|
|
44
|
+
- 新增工具 `experience_archive`:把记录移入 `store.archive`(可 `/memory restore` 恢复,写 `evictions` 审计),
|
|
45
|
+
用于清理已批准的流水账;不删除数据。
|
|
46
|
+
|
|
47
|
+
实测(真实库,清理 exp-22..25 后剩下 4 条):四条真实失败查询上 exp-26 全部排第 1
|
|
48
|
+
(0.364 / 0.373 / 0.502 / 0.502),无关查询仍为 0。测试:selftest 78 pass、e2e 19 pass。
|
|
49
|
+
|
|
50
|
+
## [1.0.6] — 2026-11-09 — 检索:停用词、特征桶与 idf 加权
|
|
51
|
+
|
|
52
|
+
现象:一条噪声较大的任务描述("跑一个会踩坑的 .mjs 脚本…内置 node.exe 模块报错")
|
|
53
|
+
在旧打分下只有 0.07,exp-26 根本没被召回。
|
|
54
|
+
|
|
55
|
+
修法:
|
|
56
|
+
|
|
57
|
+
- 停用词扩到口语填充词(随便/故意/场景/试试…),噪声不再稀释覆盖率。
|
|
58
|
+
- 新增特征桶(`#node` `#shell` `#error` `#module` `#test` `#file` `#path` `#run` `#write` `#debug` …),
|
|
59
|
+
在原始文本上判定,词表不重合也能命中;`#run`/`#write` 区分"跑脚本"与"加函数"这两类意图。
|
|
60
|
+
- 打分改为 idf 加权:跨经验常见的词(报错/模块/脚本)权重低,稀有词(node.exe/MODULE_NOT_FOUND)权重高;
|
|
61
|
+
中文 bigram 权重 ×0.45(它们最容易误撞)。
|
|
62
|
+
- 索引文件名 basename,"truncate.test.mjs" 也能匹配 "test/truncate.test.mjs"。
|
|
63
|
+
- 阈值可配:`/memory config set recallThreshold 0.1`(默认仍 0.15;实测无关查询得分为 0,
|
|
64
|
+
噪声查询下第二名 0.143,降到 0.05 会把第二名一起放进来)。
|
|
65
|
+
|
|
66
|
+
实测(真实库 7 条):噪声查询 exp-26=0.159 排第一;"新增纯函数+用例" exp-1=0.852;
|
|
67
|
+
"重命名图片" 全部 0。测试 77 pass(selftest)/ 19 pass(e2e)。
|
|
68
|
+
|
|
69
|
+
## [1.0.5] — 2026-11-09 — 第一个 npm 版本
|
|
70
|
+
|
|
71
|
+
- 发布到 npm:`dsh-experience@1.0.4` 是首个公开版本,`1.0.5` 修正安装说明。
|
|
72
|
+
- `README.md`:安装段从 `file:`/`link:` 改为 `install_bundle target=dsh-experience@<版本>`,并补升级方式(Web 插件页 Update / 重新 install)与“npm 安装是复制、改代码不生效”的说明。
|
|
73
|
+
- 新增 `tools/pack-check.mjs`(发布前按 `files` 白名单核对入包内容)与 `.github/workflows/publish.yml`(推 `v*` tag 校验版本一致、跑 selftest+e2e 后 `npm publish`)。
|
|
74
|
+
|
|
3
75
|
## [1.0.4] — 2026-11-09 — npm 发布通道
|
|
4
76
|
|
|
5
77
|
- 新增 `.github/workflows/publish.yml`:推送 `v*` tag 时先校验 tag 与 `package.json` 版本一致、跑 selftest 与 e2e,再 `npm publish --access public`。
|
package/README.md
CHANGED
|
@@ -66,38 +66,25 @@ flowchart LR
|
|
|
66
66
|
|
|
67
67
|
## 安装
|
|
68
68
|
|
|
69
|
-
###
|
|
69
|
+
### 方式一:从 npm 安装(推荐)
|
|
70
70
|
|
|
71
71
|
```text
|
|
72
|
-
plugin_manager install_bundle target=
|
|
72
|
+
plugin_manager install_bundle target=dsh-experience@1.0.5
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
安装后 `plugin_manager list_plugins` 应出现 `dsh-experience` 且 `fiberPhase: active`。
|
|
76
|
+
|
|
77
|
+
**升级**:Web 插件页对该 bundle 点 **Update**,或再执行一次 `install_bundle target=dsh-experience@<新版本>`。升级走 `listVersions` → `updateBundle`,失败会自动回滚到旧版本。
|
|
78
|
+
|
|
79
|
+
### 方式二:本地开发(Junction 链接)
|
|
76
80
|
|
|
77
81
|
```text
|
|
78
82
|
plugin_manager install_bundle target=link:<PATH-TO-dsh-experience>
|
|
79
83
|
```
|
|
80
84
|
|
|
81
|
-
|
|
85
|
+
链接安装改代码即时生效;npm 安装是复制,改代码不生效,要发新版本。
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
编辑 `~/.dsh/profiles/<profile>/package.json`:
|
|
86
|
-
|
|
87
|
-
```json
|
|
88
|
-
{
|
|
89
|
-
"dependencies": {
|
|
90
|
-
"dsh-experience": "link:<PATH-TO-dsh-experience>"
|
|
91
|
-
},
|
|
92
|
-
"dsh": {
|
|
93
|
-
"profile": {
|
|
94
|
-
"bundles": ["dsh-experience"]
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
重启 DSH 后,在任意会话里执行 `/memory list`,看到 `库 0 条` 即安装成功。
|
|
87
|
+
装完在任意会话执行 `/memory list`,看到 `库 0 条` 即安装成功。
|
|
101
88
|
|
|
102
89
|
## 命令速查
|
|
103
90
|
|
|
@@ -135,11 +122,19 @@ plugin_manager install_bundle target=link:<PATH-TO-dsh-experience>
|
|
|
135
122
|
- 注入前做长度截断(默认 2 条 / 1600 字符);
|
|
136
123
|
- 仓库不包含任何作者运行时数据,骨架见 [store.example.json](store.example.json)。
|
|
137
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
|
+
|
|
138
133
|
## 已知限制
|
|
139
134
|
|
|
140
135
|
- 召回仅在每轮第 1 步生效;
|
|
141
136
|
- `auto-verified` 默认不参与召回 —— 错误经验的代价高于多一次点击;
|
|
142
|
-
-
|
|
137
|
+
- 相似度是词覆盖打分 + 特征桶,不做向量检索(零依赖的代价);**同义改写可能漏召回**——记录里写 `MODULE_NOT_FOUND`、提问只说「模块找不到」时不一定匹配,这需要向量检索才能解决,属能力边界;
|
|
143
138
|
- 淘汰只归档不删除,需要 `restore` 才能回到召回池;
|
|
144
139
|
- 经验只描述「怎么做」,不包含业务代码本身。
|
|
145
140
|
|
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 = 'r15-store-tests'
|
|
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,39 @@ 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
|
-
return emptyStore()
|
|
78
|
+
console.warn('[experience] store unreadable, started empty:', result.error && result.error.message)
|
|
111
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
|
+
if (scrubbed) console.warn(`[experience] scrubbed ${scrubbed} legacy record(s)`)
|
|
86
|
+
return store
|
|
112
87
|
}
|
|
113
88
|
|
|
114
89
|
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)
|
|
90
|
+
S.writeStore(STORE_FILE, store)
|
|
119
91
|
}
|
|
120
92
|
|
|
121
93
|
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
|
|
94
|
+
return R.expand(text)
|
|
136
95
|
}
|
|
137
96
|
|
|
138
|
-
function
|
|
139
|
-
return
|
|
140
|
-
experience.title,
|
|
141
|
-
experience.taskType,
|
|
142
|
-
experience.when,
|
|
143
|
-
(experience.keywords || []).join(' '),
|
|
144
|
-
].join(' '))
|
|
97
|
+
function scoreExperience(experience, queryTokens, index) {
|
|
98
|
+
return R.score(R.profileText(experience), queryTokens, index)
|
|
145
99
|
}
|
|
146
100
|
|
|
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
|
|
101
|
+
// Retrieval floor. Configurable via "/memory config set recallThreshold 0.1"
|
|
102
|
+
// because the right value depends on how noisy the task descriptions are.
|
|
103
|
+
function threshold() {
|
|
104
|
+
const value = Number(CONFIG.recallThreshold)
|
|
105
|
+
return Number.isFinite(value) && value > 0 ? value : THRESHOLD
|
|
157
106
|
}
|
|
158
107
|
|
|
159
108
|
function textOfMessage(message) {
|
|
@@ -321,6 +270,7 @@ export function apply(ctx) {
|
|
|
321
270
|
|
|
322
271
|
const queryText = (payload.messages || []).map(textOfMessage).join('\n')
|
|
323
272
|
const queryTokens = tokens(queryText)
|
|
273
|
+
const index = R.corpus(store.experiences)
|
|
324
274
|
const scored = store.experiences
|
|
325
275
|
.map((experience) => ({ experience, score: scoreExperience(experience, queryTokens) }))
|
|
326
276
|
.sort((a, b) => b.score - a.score)
|
|
@@ -329,10 +279,10 @@ export function apply(ctx) {
|
|
|
329
279
|
title: experience.title,
|
|
330
280
|
status: experience.status,
|
|
331
281
|
score,
|
|
332
|
-
passed: recallable(experience) && score >=
|
|
282
|
+
passed: recallable(experience) && score >= threshold(),
|
|
333
283
|
}))
|
|
334
284
|
const chosen = scored
|
|
335
|
-
.filter((entry) => recallable(entry.experience) && entry.score >=
|
|
285
|
+
.filter((entry) => recallable(entry.experience) && entry.score >= threshold())
|
|
336
286
|
.slice(0, MAX_EXPERIENCES)
|
|
337
287
|
|
|
338
288
|
const trace = {
|
|
@@ -344,7 +294,7 @@ export function apply(ctx) {
|
|
|
344
294
|
query: queryText.slice(0, 400),
|
|
345
295
|
queryTokens: [...queryTokens].slice(0, 40),
|
|
346
296
|
candidates,
|
|
347
|
-
threshold:
|
|
297
|
+
threshold: threshold(),
|
|
348
298
|
injected: chosen.map((entry) => entry.experience.id),
|
|
349
299
|
injectionId: null,
|
|
350
300
|
scores: Object.fromEntries(chosen.map((entry) => [entry.experience.id, entry.score])),
|
|
@@ -553,10 +503,11 @@ export function apply(ctx) {
|
|
|
553
503
|
function noteGatedFailure(trace) {
|
|
554
504
|
try {
|
|
555
505
|
const queryTokens = tokens(trace.query || '')
|
|
506
|
+
const index = R.corpus(store.experiences)
|
|
556
507
|
let best = null
|
|
557
508
|
for (const existing of store.experiences) {
|
|
558
509
|
if (!['verified', 'auto-verified', 'candidate'].includes(existing.status)) continue
|
|
559
|
-
const score = scoreExperience(existing, queryTokens)
|
|
510
|
+
const score = scoreExperience(existing, queryTokens, index)
|
|
560
511
|
if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
|
|
561
512
|
}
|
|
562
513
|
if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(trace.calls || []))
|
|
@@ -622,6 +573,7 @@ export function apply(ctx) {
|
|
|
622
573
|
}
|
|
623
574
|
|
|
624
575
|
const queryTokens = tokens([draft.title, draft.taskType, draft.when, (draft.keywords || []).join(' ')].join(' '))
|
|
576
|
+
const index = R.corpus(store.experiences)
|
|
625
577
|
const sameScope = (record) => !CONFIG.scopeMustMatch || !C.scopeConflicts(record, draft)
|
|
626
578
|
const mergeable = ['verified', 'auto-verified', 'candidate']
|
|
627
579
|
const runSteps = C.summarizeCalls(trace.calls || [])
|
|
@@ -639,7 +591,7 @@ export function apply(ctx) {
|
|
|
639
591
|
if (!mergeable.includes(existing.status)) continue
|
|
640
592
|
if (!sameScope(existing)) continue
|
|
641
593
|
if (existing.taskType && draft.taskType && existing.taskType !== draft.taskType) continue
|
|
642
|
-
const score = scoreExperience(existing, queryTokens)
|
|
594
|
+
const score = scoreExperience(existing, queryTokens, index)
|
|
643
595
|
if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
|
|
644
596
|
}
|
|
645
597
|
if (best && CONFIG.scopeMustMatch && C.scopeConflicts(best.existing, draft)) best = null
|
|
@@ -830,6 +782,39 @@ export function apply(ctx) {
|
|
|
830
782
|
},
|
|
831
783
|
})
|
|
832
784
|
|
|
785
|
+
ctx.tools.register({
|
|
786
|
+
name: 'experience_archive',
|
|
787
|
+
description:
|
|
788
|
+
'Move an experience out of the active library into store.archive (reversible with /memory restore <id>). ' +
|
|
789
|
+
'Use for records a human explicitly asked to remove; it never deletes data.',
|
|
790
|
+
parameters: {
|
|
791
|
+
id: { type: 'string', required: true, description: 'Experience id, e.g. exp-22' },
|
|
792
|
+
reason: { type: 'string', description: 'Why this record is being archived' },
|
|
793
|
+
},
|
|
794
|
+
output: {
|
|
795
|
+
schema: { type: 'string' },
|
|
796
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
797
|
+
},
|
|
798
|
+
async execute(args) {
|
|
799
|
+
const index = store.experiences.findIndex((item) => item.id === args.id)
|
|
800
|
+
if (index < 0) return `未找到 ${args.id}`
|
|
801
|
+
const [target] = store.experiences.splice(index, 1)
|
|
802
|
+
store.archive = store.archive || []
|
|
803
|
+
store.archive.push({ ...target, archivedAt: new Date().toISOString() })
|
|
804
|
+
store.evictions = store.evictions || []
|
|
805
|
+
store.evictions.push({
|
|
806
|
+
id: target.id,
|
|
807
|
+
at: new Date().toISOString(),
|
|
808
|
+
by: 'human-request',
|
|
809
|
+
reason: String(args.reason || '人工要求移出活动库'),
|
|
810
|
+
rank: null,
|
|
811
|
+
ageDays: null,
|
|
812
|
+
})
|
|
813
|
+
persist()
|
|
814
|
+
return `已归档 ${target.id}(${target.title});活动库剩 ${store.experiences.length} 条,可用 /memory restore ${target.id} 恢复。`
|
|
815
|
+
},
|
|
816
|
+
})
|
|
817
|
+
|
|
833
818
|
ctx.tools.register({
|
|
834
819
|
name: 'experience_review',
|
|
835
820
|
description:
|
package/lib/capture.js
CHANGED
package/lib/recall.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Retrieval scoring: lexical tokens + a small hand-written feature layer.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: a task description and a stored recipe often share *meaning*
|
|
4
|
+
// while sharing almost no words ("跑一个会踩坑的 .mjs 脚本,node 报错" vs
|
|
5
|
+
// "pwsh / node.exe / load_workspace_dependencies"). Pure lexical coverage then
|
|
6
|
+
// scores near zero and the recipe is never recalled.
|
|
7
|
+
//
|
|
8
|
+
// Two mechanisms, both synchronous and dependency-free:
|
|
9
|
+
// 1. stop words — colloquial fillers ("随便", "故意", "测试场景") must not dilute coverage
|
|
10
|
+
// 2. features — canonical buckets ("#node", "#error", "#shell") that merge synonyms
|
|
11
|
+
//
|
|
12
|
+
// Features are derived from the raw text *before* stop-word removal, so a task
|
|
13
|
+
// that explicitly says "测试" still produces "#test" even though "测试" is dropped
|
|
14
|
+
// as a token.
|
|
15
|
+
|
|
16
|
+
export const STOP_WORDS = new Set([
|
|
17
|
+
// function words
|
|
18
|
+
'的', '了', '和', '与', '在', '是', '把', '并', '就', '还', '也', '有', '用', '要', '中',
|
|
19
|
+
'这个', '那个', '一个', '一下', '我们', '你们', '可以', '需要', '请', '帮', '让我', '给我',
|
|
20
|
+
'然后', '现在', '已经', '还是', '就是', '不要', '不用', '没有', '什么', '怎么', '这样',
|
|
21
|
+
'那样', '但是', '因为', '所以', '如果', '或者', '以及', '并且', '而且', '之后', '之前',
|
|
22
|
+
// colloquial fillers that carry no retrieval signal
|
|
23
|
+
'随便', '随便写', '故意', '特意', '场景', '测试场景', '试试', '尝试', '看看', '跑一个',
|
|
24
|
+
'随便跑', '简单', '复杂', '大概', '可能', '应该', '一会', '等下',
|
|
25
|
+
// english
|
|
26
|
+
'the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'is', 'are', 'be',
|
|
27
|
+
'it', 'this', 'that', 'then', 'use', 'run', 'can', 'will', 'should', 'just', 'some',
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
// Canonical bucket -> patterns that should activate it. Keep this list small and
|
|
31
|
+
// conservative: a bucket that fires on everything destroys ranking signal.
|
|
32
|
+
export const FEATURES = {
|
|
33
|
+
node: [/node(?:\.exe)?/, /\bmjs\b/, /\.mjs\b/, /\besm\b/, /内置\s*node/, /nodejs/],
|
|
34
|
+
shell: [/pwsh/, /powershell/, /\bshell\b/, /命令行/, /终端/, /cmd\b/],
|
|
35
|
+
error: [/报错/, /错误/, /\berror\b/, /exception/, /抛错/, /失败/, /\bfail/, /stack/],
|
|
36
|
+
module: [/模块/, /\bmodule\b/, /import\s/, /require\(/, /导出/, /export\s/],
|
|
37
|
+
test: [/测试/, /\btest\b/, /用例/, /\bspec\b/, /断言/, /assert/],
|
|
38
|
+
file: [/文件/, /脚本/, /\bfile\b/, /\.js\b/, /\.mjs\b/, /\.json\b/],
|
|
39
|
+
path: [/路径/, /目录/, /\bpath\b/, /folder/, /文件夹/],
|
|
40
|
+
python: [/python/, /\.py\b/, /pip\b/],
|
|
41
|
+
git: [/\bgit\b/, /提交/, /分支/, /仓库/, /commit/],
|
|
42
|
+
// Intent buckets: "run this script" and "add a function" are different tasks
|
|
43
|
+
// even when they mention the same files. Without these, a noisy description
|
|
44
|
+
// matches whichever record happens to share more generic nouns.
|
|
45
|
+
run: [/跑/, /运行/, /执行/, /\brun\b/, /execute/, /启动/],
|
|
46
|
+
write: [/新增/, /添加/, /创建/, /写入/, /\bwrite\b/, /\bcreate\b/, /实现/, /修改/, /重构/, /补/],
|
|
47
|
+
debug: [/排查/, /踩坑/, /修复/, /定位/, /\bdebug\b/, /为什么/, /失败原因/],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const FEATURE_PREFIX = '#'
|
|
51
|
+
|
|
52
|
+
export function features(text) {
|
|
53
|
+
const source = String(text || '').toLowerCase()
|
|
54
|
+
const out = new Set()
|
|
55
|
+
for (const [name, patterns] of Object.entries(FEATURES)) {
|
|
56
|
+
if (patterns.some((re) => re.test(source))) out.add(FEATURE_PREFIX + name)
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function tokenize(text) {
|
|
62
|
+
const out = new Set()
|
|
63
|
+
const source = String(text || '').toLowerCase()
|
|
64
|
+
for (const match of source.matchAll(/[a-z0-9_][a-z0-9_.\-/]*/g)) {
|
|
65
|
+
const token = match[0]
|
|
66
|
+
if (token.length >= 2 && !STOP_WORDS.has(token)) {
|
|
67
|
+
out.add(token)
|
|
68
|
+
// Index the basename too, so "truncate.test.mjs" matches "test/truncate.test.mjs".
|
|
69
|
+
const slash = token.lastIndexOf('/')
|
|
70
|
+
if (slash > 0 && token.length - slash > 2) out.add(token.slice(slash + 1))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const cjk = source.replace(/[^\u4e00-\u9fff]+/g, ' ')
|
|
74
|
+
for (const chunk of cjk.split(' ')) {
|
|
75
|
+
for (let i = 0; i + 1 < chunk.length; i++) {
|
|
76
|
+
const bigram = chunk.slice(i, i + 2)
|
|
77
|
+
if (!STOP_WORDS.has(bigram)) out.add(bigram)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Tokens + feature buckets for one text (a query or a stored profile). */
|
|
84
|
+
export function expand(text) {
|
|
85
|
+
const out = tokenize(text)
|
|
86
|
+
for (const feature of features(text)) out.add(feature)
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The searchable profile of a stored experience. */
|
|
91
|
+
export function profileText(experience) {
|
|
92
|
+
if (!experience) return ''
|
|
93
|
+
// Pitfalls and counter-examples are usually the only place a record names the
|
|
94
|
+
// concrete failure ("MODULE_NOT_FOUND", "CommandNotFoundException"). Scoring
|
|
95
|
+
// without them ranks "add a file" chores above the actual troubleshooting note.
|
|
96
|
+
return [
|
|
97
|
+
experience.title,
|
|
98
|
+
experience.taskType,
|
|
99
|
+
experience.when,
|
|
100
|
+
(experience.keywords || []).join(' '),
|
|
101
|
+
(experience.pitfalls || []).join(' '),
|
|
102
|
+
(experience.reverse_examples || []).join(' '),
|
|
103
|
+
].filter(Boolean).join(' ')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const round = (value) => Math.round(value * 1000) / 1000
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Document frequencies across the whole library. Rare tokens ("node.exe",
|
|
110
|
+
* "MODULE_NOT_FOUND") discriminate; common ones ("报错", "模块") must not.
|
|
111
|
+
* Without this, a noisy task description lifts every record at once.
|
|
112
|
+
*/
|
|
113
|
+
export function corpus(experiences) {
|
|
114
|
+
const df = new Map()
|
|
115
|
+
let n = 0
|
|
116
|
+
for (const experience of experiences || []) {
|
|
117
|
+
n += 1
|
|
118
|
+
for (const token of expand(profileText(experience))) df.set(token, (df.get(token) || 0) + 1)
|
|
119
|
+
}
|
|
120
|
+
return { n, df }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function idf(token, c) {
|
|
124
|
+
if (!c || !c.n) return 1
|
|
125
|
+
const d = c.df.get(token) || 0
|
|
126
|
+
return Math.log(1 + (c.n - d + 0.5) / (d + 0.5))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Feature buckets are hand-made and rare by construction: worth a little more than a plain token. */
|
|
130
|
+
const FEATURE_WEIGHTS = {
|
|
131
|
+
'#error': 3,
|
|
132
|
+
'#node': 2.6,
|
|
133
|
+
'#module': 2.2,
|
|
134
|
+
'#shell': 2,
|
|
135
|
+
'#debug': 2,
|
|
136
|
+
'#run': 1.8,
|
|
137
|
+
'#write': 1.8,
|
|
138
|
+
'#test': 1.4,
|
|
139
|
+
'#file': 1.2,
|
|
140
|
+
'#path': 1.2,
|
|
141
|
+
'#python': 1.4,
|
|
142
|
+
'#git': 1.4,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const featureWeight = (token) =>
|
|
146
|
+
token.startsWith(FEATURE_PREFIX) ? FEATURE_WEIGHTS[token] || 1.4 : 1
|
|
147
|
+
|
|
148
|
+
// A shared failure signal is worth more than any amount of shared nouns: the
|
|
149
|
+
// user is asking for help with the same class of breakage.
|
|
150
|
+
const CRITICAL_FEATURES = ['#error', '#node', '#module']
|
|
151
|
+
|
|
152
|
+
/** CJK bigrams are noisy: 脚本 / 模块 / 报错 collide across unrelated tasks. */
|
|
153
|
+
const scriptWeight = (token) => (/^[\u4e00-\u9fff]+$/.test(token) ? 0.45 : 1)
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Score a stored profile against an expanded query set.
|
|
157
|
+
* 0.75 * idf-weighted share of the query the profile covers
|
|
158
|
+
* 0.25 * unweighted share of the profile the query covers
|
|
159
|
+
* Both terms are bounded, so the result is always within [0, 1].
|
|
160
|
+
*/
|
|
161
|
+
export function score(profile, queryTokens, c) {
|
|
162
|
+
const expTokens = expand(profile)
|
|
163
|
+
if (!queryTokens || !queryTokens.size || !expTokens.size) return 0
|
|
164
|
+
let weightSum = 0
|
|
165
|
+
let weightHit = 0
|
|
166
|
+
for (const token of queryTokens) {
|
|
167
|
+
const weight = idf(token, c) * featureWeight(token) * scriptWeight(token)
|
|
168
|
+
weightSum += weight
|
|
169
|
+
if (expTokens.has(token)) weightHit += weight
|
|
170
|
+
}
|
|
171
|
+
const queryCoverage = weightSum ? weightHit / weightSum : 0
|
|
172
|
+
let expHits = 0
|
|
173
|
+
for (const token of expTokens) if (queryTokens.has(token)) expHits++
|
|
174
|
+
const expCoverage = expHits / expTokens.size
|
|
175
|
+
let critical = 0
|
|
176
|
+
for (const feature of CRITICAL_FEATURES) {
|
|
177
|
+
if (queryTokens.has(feature) && expTokens.has(feature)) critical += 1
|
|
178
|
+
}
|
|
179
|
+
const criticalBonus = critical ? 0.08 * critical : 0
|
|
180
|
+
return round(Math.min(1, 0.75 * queryCoverage + 0.25 * expCoverage + criticalBonus))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Human-readable breakdown for `/memory why` and `experience_debug`. */
|
|
184
|
+
export function explain(profile, queryText) {
|
|
185
|
+
const queryTokens = expand(queryText)
|
|
186
|
+
const expTokens = expand(profile)
|
|
187
|
+
const shared = [...queryTokens].filter((t) => expTokens.has(t))
|
|
188
|
+
return {
|
|
189
|
+
score: score(profile, queryTokens),
|
|
190
|
+
queryTokens: [...queryTokens].slice(0, 40),
|
|
191
|
+
expTokens: [...expTokens].slice(0, 40),
|
|
192
|
+
shared: shared.slice(0, 20),
|
|
193
|
+
features: shared.filter((t) => t.startsWith(FEATURE_PREFIX)),
|
|
194
|
+
}
|
|
195
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Durable store I/O, split out of index.js so the reload path is testable.
|
|
2
|
+
//
|
|
3
|
+
// The bug this exists to prevent: `loadStore()` used to rebuild only
|
|
4
|
+
// {version,nextId,nextInjectionId,experiences,injections,turns}, so every
|
|
5
|
+
// reload dropped `archive` and `evictions` — and the next persist() rewrote
|
|
6
|
+
// them out of the file, silently losing archived records.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
|
|
10
|
+
export function emptyStore() {
|
|
11
|
+
return {
|
|
12
|
+
version: 1,
|
|
13
|
+
nextId: 1,
|
|
14
|
+
nextInjectionId: 1,
|
|
15
|
+
experiences: [],
|
|
16
|
+
injections: [],
|
|
17
|
+
turns: [],
|
|
18
|
+
archive: [],
|
|
19
|
+
evictions: [],
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const array = (value) => (Array.isArray(value) ? value : [])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rebuild a store from parsed JSON without dropping fields.
|
|
27
|
+
* Unknown extra fields are preserved verbatim on purpose: a plugin upgrade that
|
|
28
|
+
* adds a table must not lose it when an older build reloads the file.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeStore(parsed) {
|
|
31
|
+
if (!parsed || typeof parsed !== 'object') throw new Error('not an object')
|
|
32
|
+
const known = {
|
|
33
|
+
version: Math.max(2, Number(parsed.version || 1)),
|
|
34
|
+
nextId: Number(parsed.nextId) || 1,
|
|
35
|
+
nextInjectionId: Number(parsed.nextInjectionId) || 1,
|
|
36
|
+
experiences: array(parsed.experiences),
|
|
37
|
+
injections: array(parsed.injections),
|
|
38
|
+
turns: array(parsed.turns),
|
|
39
|
+
archive: array(parsed.archive),
|
|
40
|
+
evictions: array(parsed.evictions),
|
|
41
|
+
}
|
|
42
|
+
return { ...parsed, ...known }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read a store file.
|
|
47
|
+
* @returns {{ store: object, error?: Error, missing?: boolean }}
|
|
48
|
+
*/
|
|
49
|
+
export function readStore(file) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(file, 'utf8')
|
|
52
|
+
return { store: normalizeStore(JSON.parse(raw)) }
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error && error.code === 'ENOENT') return { store: emptyStore(), missing: true }
|
|
55
|
+
return { store: emptyStore(), error }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Atomic-ish write: temp file + rename, UTF-8, no BOM. */
|
|
60
|
+
export function writeStore(file, store) {
|
|
61
|
+
const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
|
|
62
|
+
if (dir) fs.mkdirSync(dir, { recursive: true })
|
|
63
|
+
const tmp = `${file}.tmp`
|
|
64
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8')
|
|
65
|
+
fs.renameSync(tmp, file)
|
|
66
|
+
}
|
package/package.json
CHANGED
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,
|
|
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 里没有/);
|
package/tools/selftest.mjs
CHANGED
|
@@ -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,113 @@ 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
|
+
|
|
915
1028
|
// ---------------------------------------------------------------- summary
|
|
916
1029
|
|
|
917
1030
|
const total = pass + failures.length
|