dsh-experience 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -0
- package/index.js +19 -6
- package/lib/capture.js +0 -1
- package/lib/decay.js +20 -1
- package/lib/recall.js +23 -0
- package/lib/store.js +51 -18
- package/package.json +1 -1
- package/tools/e2e.mjs +17 -0
- package/tools/selftest.mjs +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## [1.2.1] — 2026-11-09 — 审计修复:召回语料、id 回收、模拟淘汰、恢复、G8 守卫
|
|
5
|
+
|
|
6
|
+
### 修复
|
|
7
|
+
- **召回漏传 IDF 语料**:`index.js` 召回处只传两个参数,`scoreExperience` 的第三参为空,所有词权重 1,常见词与稀有词同权——无关记录可能越过阈值被注入。现在与合并路径一致。
|
|
8
|
+
- **`nextId` 不与现有 id 对账**:载入时与 `experiences`/`archive` 的最大 `exp-N` 对齐,不再复用已存在的 id(否则 `forget`/`archive` 会打到旧记录)。
|
|
9
|
+
- **`decay --age N --apply` 用模拟未来时间真归档**:现在直接拒绝,`--age` 只用于展示。
|
|
10
|
+
- **`restore` 是空操作**:恢复时把 `lastUsedAt` 置为当前时刻,记录不会立刻重新判为 dormant。
|
|
11
|
+
- **淘汰会毁掉 G8 守卫**:`superseded` 不再进入淘汰候选(归档后同一指纹会被重新收录)。
|
|
12
|
+
- **“没什么可抓”被记成失败**:只有真的出现失败调用、且指纹与记录一致时,G3 拒绝才写 failure,普通对话不再毒化证据链。
|
|
13
|
+
- **临时文件名跨进程冲突**:`store.json.<pid>.tmp`,两个 DSH 进程同时写不再互相覆盖。
|
|
14
|
+
- **`pitfalls` 别名**:删除 `if (existing.pitfalls)`(`[]` 为真)导致 pitfalls 指向 reverse_examples 的写法。
|
|
15
|
+
|
|
16
|
+
### 测试
|
|
17
|
+
- 新增 T87–T91、T63、T64;`node tools/selftest.mjs` 101 pass / 0 fail,`node tools/e2e.mjs` 25 pass / 0 fail。
|
|
18
|
+
|
|
19
|
+
### 仍未做
|
|
20
|
+
- 工具在写盘失败时仍回“成功”(`persist` 吞异常);语料每轮仍重建 3 次;历史记录未回填 `pitfalls` 别名。
|
|
21
|
+
|
|
22
|
+
## [1.2.0] — 2026-11-09 — 计划任务召回与已知问题清单
|
|
23
|
+
|
|
24
|
+
### 修复
|
|
25
|
+
- **计划任务的轮次召回为空**:这类会话的第一条消息是 `[SCHEDULE REMINDER] … reminder_prompt_json: "…"`,
|
|
26
|
+
整段被当成查询词,搜到的是 "schedule"。新增 `stripReminder()`,从包装里取出真实 prompt 再分词;
|
|
27
|
+
原文保留在 `trace.queryRaw`。测试 T84。
|
|
28
|
+
|
|
29
|
+
### 新增
|
|
30
|
+
- `docs/KNOWN-ISSUES.md`:八条已知问题,按"会不会让经验变错"排序,每条写症状/影响/现状/绕法
|
|
31
|
+
(含 shell 落盘不算写、`set_bundle` 不重载、召回只在第 1 步、纯词面检索、单机单用户等)。
|
|
32
|
+
|
|
33
|
+
### 测试
|
|
34
|
+
- `selftest 94 pass / 0 fail`、`e2e 23 pass / 0 fail`。
|
|
35
|
+
|
|
4
36
|
## [1.1.1] — 2026-11-09 — 构建标识修正
|
|
5
37
|
|
|
6
38
|
- `BUILD` 从 `r20-scope-tools` 改为 `r21-platform`。1.1.0 的代码已含平台隔离,但包内构建标识还是上一个版本号,
|
package/README.md
CHANGED
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 = 'r23-audit-fixes'
|
|
39
39
|
|
|
40
40
|
let CONFIG = { ...C.DEFAULT_CONFIG }
|
|
41
41
|
|
|
@@ -286,11 +286,12 @@ export function apply(ctx) {
|
|
|
286
286
|
const step = payload.step
|
|
287
287
|
if (!id || step !== 1) return
|
|
288
288
|
|
|
289
|
-
const
|
|
289
|
+
const rawQuery = (payload.messages || []).map(textOfMessage).join('\n')
|
|
290
|
+
const queryText = R.stripReminder(rawQuery)
|
|
290
291
|
const queryTokens = tokens(queryText)
|
|
291
292
|
const index = R.corpus(store.experiences)
|
|
292
293
|
const scored = store.experiences
|
|
293
|
-
.map((experience) => ({ experience, score: scoreExperience(experience, queryTokens) }))
|
|
294
|
+
.map((experience) => ({ experience, score: scoreExperience(experience, queryTokens, index) }))
|
|
294
295
|
.sort((a, b) => b.score - a.score)
|
|
295
296
|
const candidates = scored.slice(0, 5).map(({ experience, score }) => ({
|
|
296
297
|
id: experience.id,
|
|
@@ -310,6 +311,7 @@ export function apply(ctx) {
|
|
|
310
311
|
endedAt: null,
|
|
311
312
|
steps: 0,
|
|
312
313
|
query: queryText.slice(0, 400),
|
|
314
|
+
queryRaw: rawQuery === queryText ? undefined : rawQuery.slice(0, 400),
|
|
313
315
|
queryTokens: [...queryTokens].slice(0, 40),
|
|
314
316
|
candidates,
|
|
315
317
|
threshold: threshold(),
|
|
@@ -520,15 +522,23 @@ export function apply(ctx) {
|
|
|
520
522
|
|
|
521
523
|
function noteGatedFailure(trace) {
|
|
522
524
|
try {
|
|
525
|
+
const calls = trace.calls || []
|
|
526
|
+
// Being refused for "nothing to capture" is not a failure of any recipe.
|
|
527
|
+
// A turn only voids a record's evidence chain when it actually hit an error
|
|
528
|
+
// while attempting that same job.
|
|
529
|
+
if (!calls.some((call) => call && call.ok === false)) return
|
|
530
|
+
const fingerprint = C.outcomeFingerprint(calls)
|
|
523
531
|
const queryTokens = tokens(trace.query || '')
|
|
524
532
|
const index = R.corpus(store.experiences)
|
|
525
533
|
let best = null
|
|
526
534
|
for (const existing of store.experiences) {
|
|
527
535
|
if (!['verified', 'auto-verified', 'candidate'].includes(existing.status)) continue
|
|
528
536
|
const score = scoreExperience(existing, queryTokens, index)
|
|
529
|
-
if (score
|
|
537
|
+
if (score < CONFIG.mergeThreshold) continue
|
|
538
|
+
if (!C.fingerprintsMatch(existing, { fingerprint })) continue
|
|
539
|
+
if (!best || score > best.score) best = { existing, score }
|
|
530
540
|
}
|
|
531
|
-
if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(
|
|
541
|
+
if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(calls))
|
|
532
542
|
} catch (error) {
|
|
533
543
|
console.warn('[experience] failure evidence failed:', error && error.message)
|
|
534
544
|
}
|
|
@@ -1101,7 +1111,10 @@ export function apply(ctx) {
|
|
|
1101
1111
|
const ageIndex = parts.indexOf('--age')
|
|
1102
1112
|
const simulatedDays = ageIndex >= 0 ? Number(parts[ageIndex + 1]) : 0
|
|
1103
1113
|
const simulated = Number.isFinite(simulatedDays) && simulatedDays > 0
|
|
1104
|
-
|
|
1114
|
+
if (simulated && parts.includes('--apply')) {
|
|
1115
|
+
return err('--age 只用于模拟展示,不能和 --apply 一起用:先用 --age 看水位,再不带 --age 执行 --apply')
|
|
1116
|
+
}
|
|
1117
|
+
const now = Date.now()
|
|
1105
1118
|
if (!parts.includes('--apply')) {
|
|
1106
1119
|
D.markDecay(store.experiences, Date.now(), CONFIG)
|
|
1107
1120
|
persist()
|
package/lib/capture.js
CHANGED
|
@@ -723,7 +723,6 @@ export function mergeInto(existing, draft) {
|
|
|
723
723
|
}
|
|
724
724
|
}
|
|
725
725
|
existing.reverse_examples = [...canonical, ...added].slice(0, 10)
|
|
726
|
-
if (existing.pitfalls) existing.pitfalls = existing.reverse_examples
|
|
727
726
|
|
|
728
727
|
const stepSeen = new Set(existing.steps || [])
|
|
729
728
|
const newSteps = []
|
package/lib/decay.js
CHANGED
|
@@ -127,7 +127,16 @@ export function selectEvictions(records, now = Date.now(), config = {}) {
|
|
|
127
127
|
return { ...base, triggered: false, evictions: [], reason: `未达容量阈值 ${threshold}(当前 ${list.length})` }
|
|
128
128
|
}
|
|
129
129
|
const eligible = list
|
|
130
|
-
.filter(
|
|
130
|
+
.filter(
|
|
131
|
+
(record) =>
|
|
132
|
+
record &&
|
|
133
|
+
record.status !== 'verified' &&
|
|
134
|
+
record.status !== 'rejected' &&
|
|
135
|
+
// Superseded records are the anti-re-adoption guard: archiving one lets the
|
|
136
|
+
// refused fingerprint be captured and recommended again.
|
|
137
|
+
record.status !== 'superseded' &&
|
|
138
|
+
!isPinned(record),
|
|
139
|
+
)
|
|
131
140
|
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
132
141
|
.filter((entry) => entry.info.dormant)
|
|
133
142
|
.sort((left, right) => left.info.rank - right.info.rank)
|
|
@@ -211,6 +220,9 @@ export function renderDecayReport(store, now = Date.now(), config = {}) {
|
|
|
211
220
|
return lines.join('\n')
|
|
212
221
|
}
|
|
213
222
|
|
|
223
|
+
export const ARCHIVE_LIMIT = 200
|
|
224
|
+
export const EVICTION_LIMIT = 500
|
|
225
|
+
|
|
214
226
|
export function applyEvictions(store, now = Date.now(), config = {}, by = 'command') {
|
|
215
227
|
const records = Array.isArray(store && store.experiences) ? store.experiences : []
|
|
216
228
|
const proposal = selectEvictions(records, now, config)
|
|
@@ -240,6 +252,10 @@ export function applyEvictions(store, now = Date.now(), config = {}, by = 'comma
|
|
|
240
252
|
ageDays: proposalItem ? proposalItem.ageDays : null,
|
|
241
253
|
})
|
|
242
254
|
}
|
|
255
|
+
// Archived records keep their whole body, so the list has to be bounded or the
|
|
256
|
+
// store grows forever on a long-lived machine.
|
|
257
|
+
if (store.archive.length > ARCHIVE_LIMIT) store.archive = store.archive.slice(-ARCHIVE_LIMIT)
|
|
258
|
+
if (store.evictions.length > EVICTION_LIMIT) store.evictions = store.evictions.slice(-EVICTION_LIMIT)
|
|
243
259
|
return { archived: archived.map((record) => record.id), triggered: true, at, reason: `已归档 ${archived.length} 条(未物理删除,可用 /memory restore <id> 恢复)` }
|
|
244
260
|
}
|
|
245
261
|
|
|
@@ -251,6 +267,9 @@ export function restoreRecord(store, id) {
|
|
|
251
267
|
delete record.archivedAt
|
|
252
268
|
delete record.archivedBy
|
|
253
269
|
delete record.decay
|
|
270
|
+
// Resetting the snapshot alone would decay again on the next pass: age is
|
|
271
|
+
// measured from the last use, so a restore has to count as a use.
|
|
272
|
+
record.lastUsedAt = new Date(Date.now()).toISOString()
|
|
254
273
|
store.experiences.push(record)
|
|
255
274
|
store.evictions = Array.isArray(store.evictions) ? store.evictions : []
|
|
256
275
|
store.evictions.push({ id, at: new Date().toISOString(), by: 'restore', reason: '人工恢复' })
|
package/lib/recall.js
CHANGED
|
@@ -87,6 +87,29 @@ export function expand(text) {
|
|
|
87
87
|
return out
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A scheduled turn starts with its delivery wrapper ("[SCHEDULE REMINDER] ...
|
|
92
|
+
* reminder_prompt_json: \"...\""). Searching that text finds the word
|
|
93
|
+
* "schedule" and nothing else, so recall reads the wrapped prompt instead.
|
|
94
|
+
*/
|
|
95
|
+
export function stripReminder(text) {
|
|
96
|
+
const raw = String(text || '')
|
|
97
|
+
if (!raw.includes('[SCHEDULE REMINDER]')) return raw
|
|
98
|
+
const spoiled = /reminder_prompt_json:\s*("(?:[^"\\]|\\.)*")/s.exec(raw)
|
|
99
|
+
if (spoiled) {
|
|
100
|
+
try {
|
|
101
|
+
const prompt = JSON.parse(spoiled[1])
|
|
102
|
+
if (prompt) return String(prompt)
|
|
103
|
+
} catch {
|
|
104
|
+
/* keep the raw text when the JSON is not what we expect */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return raw
|
|
108
|
+
.split('\n')
|
|
109
|
+
.filter((line) => !/^\s*\[SCHEDULE REMINDER\]/.test(line) && !/^\s*(schedule_id_json|occurrence_at|reminder_prompt_json)\s*:/.test(line))
|
|
110
|
+
.join('\n')
|
|
111
|
+
}
|
|
112
|
+
|
|
90
113
|
/** The searchable profile of a stored experience. */
|
|
91
114
|
export function profileText(experience) {
|
|
92
115
|
if (!experience) return ''
|
package/lib/store.js
CHANGED
|
@@ -1,15 +1,47 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// them out of the file, silently losing archived records.
|
|
1
|
+
// A turn persists the store several times (injection bookkeeping, capture, decay,
|
|
2
|
+
// trace close). Each write serialises the whole file, and by now the file carries
|
|
3
|
+
// a hundred turns, so identical rewrites are pure waste. Two cheap guards:
|
|
4
|
+
// 1. skip the write when the serialised payload equals the last one written;
|
|
5
|
+
// 2. keep a counter so the tests can prove it, instead of trusting mtime.
|
|
7
6
|
|
|
8
7
|
import fs from 'node:fs'
|
|
9
8
|
|
|
9
|
+
const lastWritten = new Map()
|
|
10
|
+
const stats = { writes: 0, skipped: 0 }
|
|
11
|
+
|
|
12
|
+
export function writeStore(file, store) {
|
|
13
|
+
const text = JSON.stringify(store, null, 2)
|
|
14
|
+
// Same bytes as the last successful write to this path means nothing changed;
|
|
15
|
+
// several listeners can persist the same snapshot within one turn.
|
|
16
|
+
if (lastWritten.get(file) === text) {
|
|
17
|
+
stats.skipped += 1
|
|
18
|
+
return { written: false }
|
|
19
|
+
}
|
|
20
|
+
const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
|
|
21
|
+
if (dir) fs.mkdirSync(dir, { recursive: true })
|
|
22
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
23
|
+
fs.writeFileSync(tmp, text, 'utf8')
|
|
24
|
+
fs.renameSync(tmp, file)
|
|
25
|
+
lastWritten.set(file, text)
|
|
26
|
+
stats.writes += 1
|
|
27
|
+
return { written: true }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Exposed for tests; also handy when diagnosing write amplification. */
|
|
31
|
+
export function writeStats() {
|
|
32
|
+
return { ...stats }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Tests need a clean slate between fixtures. */
|
|
36
|
+
export function resetWriteCache() {
|
|
37
|
+
lastWritten.clear()
|
|
38
|
+
stats.writes = 0
|
|
39
|
+
stats.skipped = 0
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
export function emptyStore() {
|
|
11
43
|
return {
|
|
12
|
-
version:
|
|
44
|
+
version: 2,
|
|
13
45
|
nextId: 1,
|
|
14
46
|
nextInjectionId: 1,
|
|
15
47
|
experiences: [],
|
|
@@ -27,11 +59,18 @@ const array = (value) => (Array.isArray(value) ? value : [])
|
|
|
27
59
|
* Unknown extra fields are preserved verbatim on purpose: a plugin upgrade that
|
|
28
60
|
* adds a table must not lose it when an older build reloads the file.
|
|
29
61
|
*/
|
|
62
|
+
const highestId = (list) =>
|
|
63
|
+
list.reduce((max, record) => {
|
|
64
|
+
const match = record && typeof record.id === 'string' ? /^exp-(\d+)$/.exec(record.id) : null;
|
|
65
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
66
|
+
}, 0);
|
|
67
|
+
|
|
30
68
|
export function normalizeStore(parsed) {
|
|
31
69
|
if (!parsed || typeof parsed !== 'object') throw new Error('not an object')
|
|
32
70
|
const known = {
|
|
33
71
|
version: Math.max(2, Number(parsed.version || 1)),
|
|
34
|
-
|
|
72
|
+
// A hand-edited or truncated store must never hand out an id that already exists.
|
|
73
|
+
nextId: Math.max(Number(parsed.nextId) || 1, highestId([...array(parsed.experiences), ...array(parsed.archive)]) + 1),
|
|
35
74
|
nextInjectionId: Number(parsed.nextInjectionId) || 1,
|
|
36
75
|
experiences: array(parsed.experiences),
|
|
37
76
|
injections: array(parsed.injections),
|
|
@@ -49,18 +88,12 @@ export function normalizeStore(parsed) {
|
|
|
49
88
|
export function readStore(file) {
|
|
50
89
|
try {
|
|
51
90
|
const raw = fs.readFileSync(file, 'utf8')
|
|
52
|
-
|
|
91
|
+
const store = normalizeStore(JSON.parse(raw))
|
|
92
|
+
// Seed the cache so a read followed by an unchanged write is skipped too.
|
|
93
|
+
lastWritten.set(file, JSON.stringify(store, null, 2))
|
|
94
|
+
return { store }
|
|
53
95
|
} catch (error) {
|
|
54
96
|
if (error && error.code === 'ENOENT') return { store: emptyStore(), missing: true }
|
|
55
97
|
return { store: emptyStore(), error }
|
|
56
98
|
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Atomic-ish write: temp file + rename, UTF-8, no BOM. */
|
|
60
|
-
export function writeStore(file, store) {
|
|
61
|
-
const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
|
|
62
|
-
if (dir) fs.mkdirSync(dir, { recursive: true })
|
|
63
|
-
const tmp = `${file}.tmp`
|
|
64
|
-
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8')
|
|
65
|
-
fs.renameSync(tmp, file)
|
|
66
99
|
}
|
package/package.json
CHANGED
package/tools/e2e.mjs
CHANGED
|
@@ -410,6 +410,23 @@ await test('T62 the same recipe in a second project stays separate', async () =>
|
|
|
410
410
|
assert.match(String(second.reason), /scope 不同|意图不同|按新任务/, `reason: ${second.reason}`)
|
|
411
411
|
})
|
|
412
412
|
|
|
413
|
+
await test('T63 --age is a simulation and refuses to combine with --apply', async () => {
|
|
414
|
+
const out = await cmd('decay --age 400 --apply');
|
|
415
|
+
assert.equal(out.kind, 'error', out.text);
|
|
416
|
+
assert.match(out.text, /不能和 --apply 一起用/);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await test('T64 a refused turn without errors does not void evidence', async () => {
|
|
420
|
+
const before = JSON.stringify(readStore().experiences.map((e) => e.proof || null));
|
|
421
|
+
await enter(A, 40, 1, '在 acme-app 里新增 pad 函数并配 node:test 用例');
|
|
422
|
+
await call(A, 'read', { file_path: 'D:\\work\\acme-app\\src\\pad.js' });
|
|
423
|
+
await call(A, 'read', { file_path: 'D:\\work\\acme-app\\test\\pad.test.mjs' });
|
|
424
|
+
await fire('agent/turn-stopping', { agent: A, turn: 40, signal: undefined });
|
|
425
|
+
const capture = readStore().turns.at(-1).capture;
|
|
426
|
+
assert.equal(capture.ok, false, JSON.stringify(capture));
|
|
427
|
+
assert.equal(JSON.stringify(readStore().experiences.map((e) => e.proof || null)), before, 'no failure evidence');
|
|
428
|
+
});
|
|
429
|
+
|
|
413
430
|
console.log('');
|
|
414
431
|
let pass = 0;
|
|
415
432
|
results.forEach((item, index) => {
|
package/tools/selftest.mjs
CHANGED
|
@@ -1198,6 +1198,95 @@ test('T83 a recipe from another OS is never merged and never recalled', () => {
|
|
|
1198
1198
|
assert.equal(C.platformConflicts({}), false)
|
|
1199
1199
|
})
|
|
1200
1200
|
|
|
1201
|
+
test('T84 a scheduled wrapper yields the task, not the wrapper', () => {
|
|
1202
|
+
const wrapped = '[SCHEDULE REMINDER]\nThis is a scheduled message from the user\nschedule_id_json: "schedule-1"\noccurrence_at: 2026-11-09T02:57:00.000Z\nreminder_prompt_json: "在 D:\\\\work\\\\platgate 下创建 platgate.js 并运行"'
|
|
1203
|
+
const task = R.stripReminder(wrapped)
|
|
1204
|
+
assert.equal(task, '在 D:\\work\\platgate 下创建 platgate.js 并运行')
|
|
1205
|
+
const tokens = R.expand(task)
|
|
1206
|
+
assert.ok(tokens.has('platgate'))
|
|
1207
|
+
assert.ok(!tokens.has('schedule'))
|
|
1208
|
+
assert.equal(R.stripReminder('普通任务'), '普通任务')
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
test('T85 an unchanged store is not rewritten', () => {
|
|
1212
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-store-'))
|
|
1213
|
+
const file = path.join(dir, 'store.json')
|
|
1214
|
+
S.resetWriteCache()
|
|
1215
|
+
const store = S.emptyStore()
|
|
1216
|
+
assert.equal(S.writeStore(file, store).written, true)
|
|
1217
|
+
assert.equal(S.writeStore(file, store).written, false)
|
|
1218
|
+
store.nextId = 7
|
|
1219
|
+
assert.equal(S.writeStore(file, store).written, true)
|
|
1220
|
+
const stats = S.writeStats()
|
|
1221
|
+
assert.equal(stats.writes, 2)
|
|
1222
|
+
assert.equal(stats.skipped, 1)
|
|
1223
|
+
// A read seeds the cache, so "load then persist an untouched snapshot" is free.
|
|
1224
|
+
S.resetWriteCache()
|
|
1225
|
+
const reread = S.readStore(file)
|
|
1226
|
+
assert.equal(S.writeStore(file, reread.store).written, false)
|
|
1227
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
test('T86 the archive keeps the newest 200 records', () => {
|
|
1231
|
+
assert.equal(D.ARCHIVE_LIMIT, 200)
|
|
1232
|
+
const store = { experiences: [], archive: [], evictions: [], nextId: 1 }
|
|
1233
|
+
for (let i = 0; i < 205; i += 1) {
|
|
1234
|
+
store.experiences.push({ id: 'exp-' + i, status: 'candidate', createdAt: '2020-01-01T00:00:00.000Z', steps: ['a', 'b'] })
|
|
1235
|
+
}
|
|
1236
|
+
const result = D.applyEvictions(store, Date.now(), { ...C.DEFAULT_CONFIG, libraryLimit: 10 }, 'test')
|
|
1237
|
+
assert.ok(result.archived.length > 0)
|
|
1238
|
+
assert.ok(store.archive.length <= D.ARCHIVE_LIMIT)
|
|
1239
|
+
assert.equal(store.archive.at(-1).archivedBy, 'test')
|
|
1240
|
+
assert.ok(store.evictions.length <= D.EVICTION_LIMIT)
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
test('T87 recall scores with the IDF corpus', () => {
|
|
1244
|
+
const source = fs.readFileSync(new URL('../index.js', import.meta.url), 'utf8')
|
|
1245
|
+
assert.ok(source.includes('scoreExperience(experience, queryTokens, index)'), 'recall passes the corpus')
|
|
1246
|
+
assert.ok(!source.includes('scoreExperience(experience, queryTokens)'), 'no call site drops the corpus')
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
test('T88 a stale nextId is reconciled with existing ids', () => {
|
|
1250
|
+
const store = S.normalizeStore({
|
|
1251
|
+
nextId: 3,
|
|
1252
|
+
experiences: [{ id: 'exp-1' }, { id: 'exp-9' }],
|
|
1253
|
+
archive: [{ id: 'exp-12' }],
|
|
1254
|
+
})
|
|
1255
|
+
assert.equal(store.nextId, 13)
|
|
1256
|
+
const empty = S.normalizeStore({ experiences: [] })
|
|
1257
|
+
assert.equal(empty.nextId, 1)
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
test('T89 superseded records are never evicted', () => {
|
|
1261
|
+
const now = Date.parse('2026-11-09T00:00:00Z')
|
|
1262
|
+
const old = new Date(now - 400 * 86400000).toISOString()
|
|
1263
|
+
const records = []
|
|
1264
|
+
for (let i = 0; i < 159; i += 1) {
|
|
1265
|
+
records.push({ id: 'exp-' + (100 + i), status: 'candidate', createdAt: old })
|
|
1266
|
+
}
|
|
1267
|
+
records.push({ id: 'exp-super', status: 'superseded', createdAt: old })
|
|
1268
|
+
const result = D.selectEvictions(records, now, {})
|
|
1269
|
+
assert.equal(result.triggered, true)
|
|
1270
|
+
assert.ok(result.evictions.length > 0, 'old candidates are proposed')
|
|
1271
|
+
assert.ok(!result.evictions.some((item) => item.id === 'exp-super'), 'the G8 guard stays in the library')
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
test('T90 restore makes a record young again', () => {
|
|
1275
|
+
const now = Date.parse('2026-11-09T00:00:00Z')
|
|
1276
|
+
const old = new Date(now - 400 * 86400000).toISOString()
|
|
1277
|
+
const store = { experiences: [], archive: [{ id: 'exp-old', status: 'candidate', createdAt: old, steps: [] }], evictions: [] }
|
|
1278
|
+
const restored = D.restoreRecord(store, 'exp-old')
|
|
1279
|
+
assert.ok(restored, 'record comes back')
|
|
1280
|
+
D.markDecay(store.experiences, now, {})
|
|
1281
|
+
const decay = store.experiences[0].decay
|
|
1282
|
+
assert.equal(decay.state, 'active')
|
|
1283
|
+
assert.equal(decay.ageDays, 0)
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
test('T91 a fresh store starts at schema version 2', () => {
|
|
1287
|
+
assert.equal(S.emptyStore().version, 2)
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1201
1290
|
// ---------------------------------------------------------------- summary
|
|
1202
1291
|
|
|
1203
1292
|
const total = pass + failures.length
|