dsh-experience 0.0.0-stage → 1.0.4
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 +225 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +156 -2
- package/USER-GUIDE.md +92 -0
- package/config.example.json +13 -0
- package/cordis.patch.yml +4 -0
- package/index.js +1207 -0
- package/lib/capture.js +663 -0
- package/lib/decay.js +253 -0
- package/lib/evidence.js +206 -0
- package/lib/lookup.js +53 -0
- package/package.json +60 -4
- package/store.example.json +10 -0
- package/tools/e2e.mjs +342 -0
- package/tools/selftest.mjs +925 -0
package/lib/decay.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-experience / lib/decay.js — M3.1 decay ranking (pure, zero dependency).
|
|
3
|
+
*
|
|
4
|
+
* rank = score × freshness × utility × pin
|
|
5
|
+
* freshness = 0.5 ^ (ageDays / halfLife) halfLife: verified 180d / auto-verified 60d / candidate 30d
|
|
6
|
+
* utility = clamp(1 + log2(1 + uses) / 10, 1, 1.5)
|
|
7
|
+
* pin = Infinity for human-taught or explicitly pinned records
|
|
8
|
+
*
|
|
9
|
+
* Invariants:
|
|
10
|
+
* - decay never rewrites the stored similarity score; it only ranks and selects.
|
|
11
|
+
* - `verified` may become dormant, but is never proposed for eviction.
|
|
12
|
+
* - `now` is always injected so tests can travel through time.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const DECAY_DEFAULTS = Object.freeze({
|
|
16
|
+
halfLife: Object.freeze({ verified: 180, 'auto-verified': 60, candidate: 30 }),
|
|
17
|
+
dormantFactor: 2,
|
|
18
|
+
utilityCap: 1.5,
|
|
19
|
+
utilityScale: 10,
|
|
20
|
+
capacityRatio: 0.8,
|
|
21
|
+
evictBatch: 5,
|
|
22
|
+
libraryLimit: 200,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 86400000
|
|
26
|
+
|
|
27
|
+
function num(value, fallback) {
|
|
28
|
+
const parsed = Number(value)
|
|
29
|
+
return Number.isFinite(parsed) ? parsed : fallback
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function halfLifeOf(status, config = {}) {
|
|
33
|
+
const table = { ...DECAY_DEFAULTS.halfLife, ...(config.halfLife || {}) }
|
|
34
|
+
const days = num(table[status], DECAY_DEFAULTS.halfLife.candidate)
|
|
35
|
+
return days > 0 ? days : DECAY_DEFAULTS.halfLife.candidate
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function timeOf(value) {
|
|
39
|
+
if (!value) return null
|
|
40
|
+
const parsed = Date.parse(value)
|
|
41
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function anchorMs(record) {
|
|
45
|
+
const proof = record && record.proof ? record.proof : {}
|
|
46
|
+
const candidates = [
|
|
47
|
+
timeOf(record && record.lastUsedAt),
|
|
48
|
+
timeOf(record && record.lastSuccessAt),
|
|
49
|
+
timeOf(proof.lastSuccessAt),
|
|
50
|
+
timeOf(record && record.createdAt),
|
|
51
|
+
timeOf(record && record.at),
|
|
52
|
+
].filter((value) => value !== null)
|
|
53
|
+
return candidates.length ? Math.max(...candidates) : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Age in days. Unknown or future timestamps count as fresh (age 0): never decay something we cannot date. */
|
|
57
|
+
export function ageDays(record, now = Date.now()) {
|
|
58
|
+
const anchor = anchorMs(record)
|
|
59
|
+
const current = num(now, Date.now())
|
|
60
|
+
if (anchor === null) return 0
|
|
61
|
+
const days = (current - anchor) / DAY_MS
|
|
62
|
+
return days > 0 ? days : 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function freshnessOf(record, now = Date.now(), config = {}) {
|
|
66
|
+
const halfLife = halfLifeOf(record && record.status, config)
|
|
67
|
+
return 0.5 ** (ageDays(record, now) / halfLife)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function utilityOf(uses, config = {}) {
|
|
71
|
+
const cap = num(config.utilityCap, DECAY_DEFAULTS.utilityCap)
|
|
72
|
+
const scale = num(config.utilityScale, DECAY_DEFAULTS.utilityScale)
|
|
73
|
+
const count = Math.max(0, num(uses, 0))
|
|
74
|
+
const value = 1 + Math.log2(1 + count) / scale
|
|
75
|
+
return Math.min(cap, Math.max(1, value))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isPinned(record) {
|
|
79
|
+
if (!record) return false
|
|
80
|
+
return record.pinned === true || record.teach === true || Boolean(record.source && record.source.teach)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function rankOf(record, now = Date.now(), options = {}) {
|
|
84
|
+
if (isPinned(record)) return Infinity
|
|
85
|
+
const score = num(options.score, 1)
|
|
86
|
+
return score * freshnessOf(record, now, options) * utilityOf(record && record.uses, options)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Lifecycle for display only: archived is decided by selectEvictions, never here. */
|
|
90
|
+
export function stateOf(record, now = Date.now(), config = {}) {
|
|
91
|
+
const halfLife = halfLifeOf(record && record.status, config)
|
|
92
|
+
const factor = num(config.dormantFactor, DECAY_DEFAULTS.dormantFactor)
|
|
93
|
+
const age = ageDays(record, now)
|
|
94
|
+
const dormantAt = halfLife * factor
|
|
95
|
+
const dormant = !isPinned(record) && age >= dormantAt
|
|
96
|
+
return {
|
|
97
|
+
state: dormant ? 'dormant' : 'active',
|
|
98
|
+
dormant,
|
|
99
|
+
ageDays: Math.round(age * 10) / 10,
|
|
100
|
+
halfLife,
|
|
101
|
+
dormantAt,
|
|
102
|
+
freshness: freshnessOf(record, now, config),
|
|
103
|
+
utility: utilityOf(record && record.uses, config),
|
|
104
|
+
rank: rankOf(record, now, config),
|
|
105
|
+
pinned: isPinned(record),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function describeDecay(record, now = Date.now(), config = {}) {
|
|
110
|
+
const info = stateOf(record, now, config)
|
|
111
|
+
const rank = info.pinned ? 'pinned' : info.rank.toFixed(4)
|
|
112
|
+
return `rank=${rank} fresh=${info.freshness.toFixed(3)} uses=${num(record && record.uses, 0)} age=${info.ageDays}d state=${info.state}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Capacity-driven eviction proposals. Never deletes anything, never touches verified/pinned.
|
|
117
|
+
* Returns proposals only once the library reaches capacityRatio × libraryLimit.
|
|
118
|
+
*/
|
|
119
|
+
export function selectEvictions(records, now = Date.now(), config = {}) {
|
|
120
|
+
const list = Array.isArray(records) ? records : []
|
|
121
|
+
const libraryLimit = num(config.libraryLimit, DECAY_DEFAULTS.libraryLimit)
|
|
122
|
+
const ratio = num(config.capacityRatio, DECAY_DEFAULTS.capacityRatio)
|
|
123
|
+
const batch = Math.max(1, num(config.evictBatch, DECAY_DEFAULTS.evictBatch))
|
|
124
|
+
const threshold = Math.ceil(libraryLimit * ratio)
|
|
125
|
+
const base = { count: list.length, threshold, libraryLimit }
|
|
126
|
+
if (list.length < threshold) {
|
|
127
|
+
return { ...base, triggered: false, evictions: [], reason: `未达容量阈值 ${threshold}(当前 ${list.length})` }
|
|
128
|
+
}
|
|
129
|
+
const eligible = list
|
|
130
|
+
.filter((record) => record && record.status !== 'verified' && !isPinned(record))
|
|
131
|
+
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
132
|
+
.filter((entry) => entry.info.dormant)
|
|
133
|
+
.sort((left, right) => left.info.rank - right.info.rank)
|
|
134
|
+
if (!eligible.length) {
|
|
135
|
+
return { ...base, triggered: true, evictions: [], reason: '已达容量阈值,但没有可淘汰的 dormant 候选' }
|
|
136
|
+
}
|
|
137
|
+
const evictions = eligible.slice(0, batch).map((entry) => ({
|
|
138
|
+
id: entry.record.id,
|
|
139
|
+
status: entry.record.status,
|
|
140
|
+
reason: `dormant ${entry.info.ageDays}d(半衰期 ${entry.info.halfLife}d),rank 最低`,
|
|
141
|
+
rank: entry.info.rank,
|
|
142
|
+
ageDays: entry.info.ageDays,
|
|
143
|
+
}))
|
|
144
|
+
return { ...base, triggered: true, evictions, reason: `已达容量阈值 ${threshold},建议淘汰 ${evictions.length} 条` }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function markDecay(records, now = Date.now(), config = {}) {
|
|
148
|
+
const list = Array.isArray(records) ? records : []
|
|
149
|
+
const stamp = new Date(num(now, Date.now())).toISOString()
|
|
150
|
+
let changed = 0
|
|
151
|
+
for (const record of list) {
|
|
152
|
+
if (!record) continue
|
|
153
|
+
const info = stateOf(record, now, config)
|
|
154
|
+
const next = {
|
|
155
|
+
state: info.state,
|
|
156
|
+
ageDays: info.ageDays,
|
|
157
|
+
halfLife: info.halfLife,
|
|
158
|
+
freshness: Math.round(info.freshness * 1000) / 1000,
|
|
159
|
+
utility: Math.round(info.utility * 1000) / 1000,
|
|
160
|
+
rank: info.pinned ? null : Math.round(info.rank * 10000) / 10000,
|
|
161
|
+
pinned: info.pinned,
|
|
162
|
+
at: stamp,
|
|
163
|
+
}
|
|
164
|
+
const prev = record.decay || {}
|
|
165
|
+
if (prev.state !== next.state || prev.ageDays !== next.ageDays || prev.rank !== next.rank || prev.pinned !== next.pinned) {
|
|
166
|
+
record.decay = next
|
|
167
|
+
changed++
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return changed
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function decayLine(record, now = Date.now(), config = {}) {
|
|
174
|
+
const info = stateOf(record, now, config)
|
|
175
|
+
const rank = info.pinned ? 'pinned' : info.rank.toFixed(4)
|
|
176
|
+
return `${record.id} [${record.status}] rank=${rank} state=${info.state} age=${info.ageDays}d fresh=${info.freshness.toFixed(3)} uses=${num(record.uses, 0)}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function renderDecayReport(store, now = Date.now(), config = {}) {
|
|
180
|
+
const records = Array.isArray(store && store.experiences) ? store.experiences : []
|
|
181
|
+
const proposal = selectEvictions(records, now, config)
|
|
182
|
+
const counts = { active: 0, dormant: 0, pinned: 0 }
|
|
183
|
+
const sorted = records
|
|
184
|
+
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
185
|
+
.sort((left, right) => {
|
|
186
|
+
if (left.info.pinned !== right.info.pinned) return left.info.pinned ? 1 : -1
|
|
187
|
+
return left.info.rank - right.info.rank
|
|
188
|
+
})
|
|
189
|
+
for (const entry of sorted) {
|
|
190
|
+
if (entry.info.pinned) counts.pinned++
|
|
191
|
+
else counts[entry.info.state]++
|
|
192
|
+
}
|
|
193
|
+
const eligible = new Set(proposal.evictions.map((item) => item.id))
|
|
194
|
+
const top = sorted.filter((entry) => !entry.info.pinned).slice(0, 5)
|
|
195
|
+
const lines = [
|
|
196
|
+
`[M3.3 衰减报告] 库 ${records.length} 条 · active ${counts.active} / dormant ${counts.dormant} / pinned ${counts.pinned}`,
|
|
197
|
+
`容量: ${proposal.count}/${proposal.libraryLimit}(阈值 ${proposal.threshold} · 比例 ${num(config.capacityRatio, DECAY_DEFAULTS.capacityRatio)})· ${proposal.triggered ? '已达阈值' : '未达阈值'}`,
|
|
198
|
+
proposal.triggered
|
|
199
|
+
? `建议归档 ${proposal.evictions.length} 条(最多 ${num(config.evictBatch, DECAY_DEFAULTS.evictBatch)} 条/次,按 rank 升序):`
|
|
200
|
+
: '容量尚未告警: 以下为 rank 最低的 5 条观察项,无需处理。',
|
|
201
|
+
...(top.length
|
|
202
|
+
? top.map((entry, index) => ` ${index + 1}. ${decayLine(entry.record, now, config)}${eligible.has(entry.record.id) ? ' ← 可归档' : ''}`)
|
|
203
|
+
: [' (库为空)']),
|
|
204
|
+
'只列出建议,不删除任何记录。确认后运行 /memory decay --apply(移入 archive,可用 /memory restore <id> 恢复)。',
|
|
205
|
+
]
|
|
206
|
+
return lines.join('\n')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function applyEvictions(store, now = Date.now(), config = {}, by = 'command') {
|
|
210
|
+
const records = Array.isArray(store && store.experiences) ? store.experiences : []
|
|
211
|
+
const proposal = selectEvictions(records, now, config)
|
|
212
|
+
if (!proposal.triggered) return { archived: [], triggered: false, reason: proposal.reason }
|
|
213
|
+
const ids = new Set(proposal.evictions.map((item) => item.id))
|
|
214
|
+
const keep = []
|
|
215
|
+
const archived = []
|
|
216
|
+
for (const record of records) {
|
|
217
|
+
const guard = record && ids.has(record.id) && record.status !== 'verified' && !isPinned(record)
|
|
218
|
+
if (guard) archived.push(record)
|
|
219
|
+
else keep.push(record)
|
|
220
|
+
}
|
|
221
|
+
if (!archived.length) return { archived: [], triggered: true, reason: '没有可归档的 dormant 候选' }
|
|
222
|
+
const at = new Date(num(now, Date.now())).toISOString()
|
|
223
|
+
store.experiences = keep
|
|
224
|
+
store.archive = Array.isArray(store.archive) ? store.archive : []
|
|
225
|
+
store.evictions = Array.isArray(store.evictions) ? store.evictions : []
|
|
226
|
+
for (const record of archived) {
|
|
227
|
+
store.archive.push({ ...record, archivedAt: at, archivedBy: by })
|
|
228
|
+
const proposalItem = proposal.evictions.find((item) => item.id === record.id)
|
|
229
|
+
store.evictions.push({
|
|
230
|
+
id: record.id,
|
|
231
|
+
at,
|
|
232
|
+
by,
|
|
233
|
+
reason: proposalItem ? proposalItem.reason : 'dormant',
|
|
234
|
+
rank: proposalItem ? proposalItem.rank : null,
|
|
235
|
+
ageDays: proposalItem ? proposalItem.ageDays : null,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
return { archived: archived.map((record) => record.id), triggered: true, at, reason: `已归档 ${archived.length} 条(未物理删除,可用 /memory restore <id> 恢复)` }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function restoreRecord(store, id) {
|
|
242
|
+
const archive = Array.isArray(store && store.archive) ? store.archive : []
|
|
243
|
+
const index = archive.findIndex((record) => record && record.id === id)
|
|
244
|
+
if (index < 0) return null
|
|
245
|
+
const [record] = archive.splice(index, 1)
|
|
246
|
+
delete record.archivedAt
|
|
247
|
+
delete record.archivedBy
|
|
248
|
+
delete record.decay
|
|
249
|
+
store.experiences.push(record)
|
|
250
|
+
store.evictions = Array.isArray(store.evictions) ? store.evictions : []
|
|
251
|
+
store.evictions.push({ id, at: new Date().toISOString(), by: 'restore', reason: '人工恢复' })
|
|
252
|
+
return record
|
|
253
|
+
}
|
package/lib/evidence.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-experience / lib/evidence.js — L2 evidence accumulation (M2).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: no ctx, no fs, no model calls. The only thing this module
|
|
5
|
+
* knows how to do is count *facts* (independent sessions, step stability,
|
|
6
|
+
* failures) and state, in plain words, why a candidate is not promoted yet.
|
|
7
|
+
*
|
|
8
|
+
* Trust boundary: nothing here can set `status: 'verified'`. The strongest
|
|
9
|
+
* outcome is `auto-verified`, which does not participate in recall unless the
|
|
10
|
+
* human turns `allowAutoVerified` on.
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
export const EVIDENCE_LIMIT = 20
|
|
15
|
+
|
|
16
|
+
export const EVIDENCE_DEFAULTS = {
|
|
17
|
+
autoPromote: false,
|
|
18
|
+
minDistinctSessions: 2,
|
|
19
|
+
stepsJaccard: 0.7,
|
|
20
|
+
allowAutoVerified: false,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function emptyProof() {
|
|
24
|
+
return { runs: [], distinctSessions: 0, minStability: 1, lastSuccessAt: null, lastFailureAt: null }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ensureProof(record) {
|
|
28
|
+
if (!record || typeof record !== 'object') return emptyProof()
|
|
29
|
+
if (!record.proof || typeof record.proof !== 'object') record.proof = emptyProof()
|
|
30
|
+
const proof = record.proof
|
|
31
|
+
if (!Array.isArray(proof.runs)) proof.runs = []
|
|
32
|
+
if (typeof proof.distinctSessions !== 'number') proof.distinctSessions = 0
|
|
33
|
+
if (typeof proof.minStability !== 'number') proof.minStability = 1
|
|
34
|
+
if (!('lastSuccessAt' in proof)) proof.lastSuccessAt = null
|
|
35
|
+
if (!('lastFailureAt' in proof)) proof.lastFailureAt = null
|
|
36
|
+
return proof
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function stepsHash(steps) {
|
|
40
|
+
const text = (steps || []).map(String).join('|')
|
|
41
|
+
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 12)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Set Jaccard of two step lists; 1 when both are empty (nothing drifted). */
|
|
45
|
+
export function jaccardSteps(a, b) {
|
|
46
|
+
const left = new Set((a || []).map(String))
|
|
47
|
+
const right = new Set((b || []).map(String))
|
|
48
|
+
if (!left.size && !right.size) return 1
|
|
49
|
+
let intersection = 0
|
|
50
|
+
for (const item of left) if (right.has(item)) intersection++
|
|
51
|
+
const union = left.size + right.size - intersection
|
|
52
|
+
return union ? Math.round((intersection / union) * 1000) / 1000 : 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Append one run to the record's proof and refresh the derived counters.
|
|
57
|
+
* `steps` is the run's own step list; stability is measured against the
|
|
58
|
+
* record's canonical steps at write time so promotion stays a pure decision.
|
|
59
|
+
*/
|
|
60
|
+
export function recordEvidence(record, run) {
|
|
61
|
+
const proof = ensureProof(record)
|
|
62
|
+
const at = run.at || new Date().toISOString()
|
|
63
|
+
const outcome = run.outcome === 'failure' ? 'failure' : 'success'
|
|
64
|
+
// Stability is measured between consecutive successful runs: the record steps
|
|
65
|
+
// may predate structured evidence and would otherwise look like drift forever.
|
|
66
|
+
const previous = [...proof.runs]
|
|
67
|
+
.reverse()
|
|
68
|
+
.find((item) => item.outcome === 'success' && Array.isArray(item.steps) && item.steps.length)
|
|
69
|
+
const baseline = previous ? previous.steps : record.steps || []
|
|
70
|
+
const stability = run.byFingerprint || (run.verificationOnly && run.green) ? 1 : jaccardSteps(baseline, run.steps || [])
|
|
71
|
+
const entry = {
|
|
72
|
+
sessionId: String(run.sessionId || 'unknown'),
|
|
73
|
+
turn: run.turn ?? null,
|
|
74
|
+
at,
|
|
75
|
+
outcome,
|
|
76
|
+
stability,
|
|
77
|
+
byFingerprint: Boolean(run.byFingerprint),
|
|
78
|
+
verificationOnly: Boolean(run.verificationOnly),
|
|
79
|
+
green: Boolean(run.green),
|
|
80
|
+
errors: Number(run.errors || 0),
|
|
81
|
+
steps: (run.steps || []).slice(0, 8).map(String),
|
|
82
|
+
stepsHash: stepsHash(run.steps),
|
|
83
|
+
}
|
|
84
|
+
proof.runs = [...proof.runs, entry].slice(-EVIDENCE_LIMIT)
|
|
85
|
+
|
|
86
|
+
const sessions = new Set(proof.runs.filter((item) => item.outcome === 'success').map((item) => item.sessionId))
|
|
87
|
+
proof.distinctSessions = sessions.size
|
|
88
|
+
const successes = proof.runs.filter((item) => item.outcome === 'success')
|
|
89
|
+
// The first run with a step snapshot only establishes the baseline; comparing it
|
|
90
|
+
// against pre-snapshot record steps says nothing about reproducibility.
|
|
91
|
+
const comparable = successes.filter((item) => Array.isArray(item.steps) && item.steps.length).slice(1)
|
|
92
|
+
proof.minStability = comparable.length ? Math.min(...comparable.map((item) => item.stability)) : null
|
|
93
|
+
if (outcome === 'failure') proof.lastFailureAt = at
|
|
94
|
+
else proof.lastSuccessAt = at
|
|
95
|
+
return { entry, proof }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Why is this record (not) promotion-ready? Always returns a human sentence,
|
|
100
|
+
* which `/memory debug` prints verbatim under [2.5 证据].
|
|
101
|
+
*/
|
|
102
|
+
export function promotionState(record, config = {}, at = new Date().toISOString()) {
|
|
103
|
+
const cfg = { ...EVIDENCE_DEFAULTS, ...config }
|
|
104
|
+
const proof = ensureProof(record)
|
|
105
|
+
const status = record && record.status
|
|
106
|
+
if (status === 'verified') return { eligible: false, code: 'already-human', reason: '已人工确认(verified)' }
|
|
107
|
+
if (status === 'rejected') return { eligible: false, code: 'rejected', reason: '已被人工拒绝(rejected)' }
|
|
108
|
+
|
|
109
|
+
const successes = proof.runs.filter((item) => item.outcome === 'success')
|
|
110
|
+
const sessions = new Set(successes.map((item) => item.sessionId))
|
|
111
|
+
const withSteps = successes.filter((item) => Array.isArray(item.steps) && item.steps.length)
|
|
112
|
+
const comparable = withSteps.slice(1)
|
|
113
|
+
// The result fingerprint (same artifact, green tests) outranks step similarity:
|
|
114
|
+
// a run that re-proves the same terminal state is a 1.0 no matter how lean it was.
|
|
115
|
+
const fingerprintGreen = Boolean(record && record.fingerprint && record.fingerprint.green)
|
|
116
|
+
const effective = (item) =>
|
|
117
|
+
fingerprintGreen || (item && (item.byFingerprint || (item.verificationOnly && item.green))) ? 1 : item.stability
|
|
118
|
+
const stability = comparable.length ? Math.min(...comparable.map(effective)) : null
|
|
119
|
+
const fingerprinted = fingerprintGreen ? successes.length : successes.filter((item) => item && item.byFingerprint).length
|
|
120
|
+
|
|
121
|
+
const failedAfterSuccess =
|
|
122
|
+
Boolean(proof.lastFailureAt) &&
|
|
123
|
+
(!proof.lastSuccessAt || String(proof.lastFailureAt) >= String(proof.lastSuccessAt))
|
|
124
|
+
|
|
125
|
+
if (!proof.runs.length) {
|
|
126
|
+
return { eligible: false, code: 'no-runs', reason: '尚无结构化证据:迁移只补字段、不伪造历史运行,需要一次新的成功轨迹', sessions: 0, stability }
|
|
127
|
+
}
|
|
128
|
+
const stabilityText = stability === null ? "缺可比较快照" : String(stability)
|
|
129
|
+
const detail = `独立 session ${sessions.size}/${cfg.minDistinctSessions},稳定性 ${stabilityText}(线 ${cfg.stepsJaccard}${fingerprinted ? `,指纹等效 ${fingerprinted}` : ''})`
|
|
130
|
+
if (!cfg.autoPromote) {
|
|
131
|
+
return { eligible: false, code: 'policy-off', reason: `autoPromote=false:策略关闭,${detail}`, sessions: sessions.size, stability }
|
|
132
|
+
}
|
|
133
|
+
if (failedAfterSuccess) {
|
|
134
|
+
return { eligible: false, code: 'failure-after-success', reason: `最后一次成功之后出现失败,证据链作废;${detail}`, sessions: sessions.size, stability }
|
|
135
|
+
}
|
|
136
|
+
if (sessions.size < cfg.minDistinctSessions) {
|
|
137
|
+
return { eligible: false, code: 'sessions', reason: `未满 ${cfg.minDistinctSessions} 个独立 session;${detail}`, sessions: sessions.size, stability }
|
|
138
|
+
}
|
|
139
|
+
if (stability === null) {
|
|
140
|
+
const missing = 2 - withSteps.length
|
|
141
|
+
return {
|
|
142
|
+
eligible: false,
|
|
143
|
+
code: 'needs-baseline',
|
|
144
|
+
reason: `已有 ${successes.length} 次成功,但可比较的步骤快照不足(还需 ${missing} 次同类成功运行);${detail}`,
|
|
145
|
+
sessions: sessions.size,
|
|
146
|
+
stability,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (stability < cfg.stepsJaccard) {
|
|
150
|
+
return { eligible: false, code: 'drift', reason: `步骤稳定性 ${stability} < ${cfg.stepsJaccard},视为不同变体;${detail}`, sessions: sessions.size, stability }
|
|
151
|
+
}
|
|
152
|
+
return { eligible: true, code: 'ready', reason: `满足升级条件:${detail}`, sessions: sessions.size, stability }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A fingerprint merge proves the terminal state: earlier step drift stops counting
|
|
157
|
+
* and every recorded success becomes a 1.0 equivalence. Exported so the caller that
|
|
158
|
+
* detected the match (runCapture) can stamp it before promotion is evaluated.
|
|
159
|
+
*/
|
|
160
|
+
export function markFingerprintEquivalent(record, at = new Date().toISOString()) {
|
|
161
|
+
const proof = ensureProof(record)
|
|
162
|
+
let changed = 0
|
|
163
|
+
for (const run of proof.runs) {
|
|
164
|
+
if (run.outcome !== 'success') continue
|
|
165
|
+
if (run.byFingerprint && run.stability === 1) continue
|
|
166
|
+
run.byFingerprint = true
|
|
167
|
+
run.stability = 1
|
|
168
|
+
changed++
|
|
169
|
+
}
|
|
170
|
+
proof.equivalentAt = at
|
|
171
|
+
return { changed, at }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Apply the strongest non-human promotion: candidate -> auto-verified.
|
|
176
|
+
* Never writes `verified`; that status only comes from `/memory`.
|
|
177
|
+
*/
|
|
178
|
+
export function promote(record, config = {}, at = new Date().toISOString()) {
|
|
179
|
+
const state = promotionState(record, config, at)
|
|
180
|
+
if (!state.eligible) return { promoted: false, ...state }
|
|
181
|
+
if (record.status !== 'candidate') return { promoted: false, code: 'not-candidate', reason: `当前状态 ${record.status} 不走证据升级` }
|
|
182
|
+
record.status = 'auto-verified'
|
|
183
|
+
record.verify = {
|
|
184
|
+
by: 'evidence',
|
|
185
|
+
at,
|
|
186
|
+
method: 'l2-evidence',
|
|
187
|
+
sessions: state.sessions,
|
|
188
|
+
stability: state.stability,
|
|
189
|
+
}
|
|
190
|
+
record.confidence = 0.55
|
|
191
|
+
return { promoted: true, ...state }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Idempotent schema migration; returns whether anything changed. */
|
|
195
|
+
export function migrateStore(store) {
|
|
196
|
+
if (!store || typeof store !== 'object') return { migrated: false, count: 0, version: 1 }
|
|
197
|
+
let count = 0
|
|
198
|
+
for (const record of store.experiences || []) {
|
|
199
|
+
if (!record || typeof record !== 'object') continue
|
|
200
|
+
if (!record.proof) count++
|
|
201
|
+
ensureProof(record)
|
|
202
|
+
}
|
|
203
|
+
const previous = Number(store.version || 1)
|
|
204
|
+
store.version = Math.max(2, previous)
|
|
205
|
+
return { migrated: previous < 2 || count > 0, count, version: store.version }
|
|
206
|
+
}
|
package/lib/lookup.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lookup rendering for the `experience_lookup` tool.
|
|
3
|
+
*
|
|
4
|
+
* The tool accepts a list of ids. Some of them may not exist (typo, deleted
|
|
5
|
+
* candidate, or an id from another machine). Returning only the hits used to
|
|
6
|
+
* hide that fact, so the caller could not tell "exp-9 does not exist" from
|
|
7
|
+
* "exp-9 exists but was not requested". `renderLookup` keeps the historical
|
|
8
|
+
* output byte-for-byte in both the all-hit and no-hit cases, and only appends a
|
|
9
|
+
* `未找到:…` line when the request was *partially* satisfied.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const JOINER = '\n\n'
|
|
13
|
+
|
|
14
|
+
function idsOf(records) {
|
|
15
|
+
const ids = new Set()
|
|
16
|
+
for (const record of records || []) {
|
|
17
|
+
if (record && record.id != null) ids.add(String(record.id))
|
|
18
|
+
}
|
|
19
|
+
return ids
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Split wanted ids into hits and unique missing ids.
|
|
24
|
+
* Hits keep *store* order (identical to the historical `records.filter(...)`),
|
|
25
|
+
* misses keep request order and are de-duplicated.
|
|
26
|
+
*/
|
|
27
|
+
export function splitLookup(wanted, records) {
|
|
28
|
+
const list = Array.isArray(wanted) ? wanted.map(String) : []
|
|
29
|
+
const wantedSet = new Set(list)
|
|
30
|
+
const found = (records || []).filter(
|
|
31
|
+
(record) => record && record.id != null && wantedSet.has(String(record.id)),
|
|
32
|
+
)
|
|
33
|
+
const known = idsOf(records)
|
|
34
|
+
const missing = []
|
|
35
|
+
for (const id of list) {
|
|
36
|
+
if (!known.has(id) && !missing.includes(id)) missing.push(id)
|
|
37
|
+
}
|
|
38
|
+
return { found, missing }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render the tool reply.
|
|
43
|
+
* - all hits, no misses -> body only (unchanged)
|
|
44
|
+
* - no hits -> `未找到:<requested ids or (空)>` (unchanged)
|
|
45
|
+
* - partial -> body, blank line, `未找到:<missing ids>`
|
|
46
|
+
*/
|
|
47
|
+
export function renderLookup(wanted, records, format = (record) => String(record)) {
|
|
48
|
+
const list = Array.isArray(wanted) ? wanted.map(String) : []
|
|
49
|
+
const { found, missing } = splitLookup(list, records)
|
|
50
|
+
if (!found.length) return `未找到:${list.join(', ') || '(空)'}`
|
|
51
|
+
const body = found.map((record) => format(record)).join(JOINER)
|
|
52
|
+
return missing.length ? `${body}${JOINER}未找到:${missing.join(', ')}` : body
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-experience",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "Cross-session self-learning plugin for DeepSeek Harness: auto-capture, fingerprint-verified promotion, lifecycle decay.",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"dsh",
|
|
11
|
+
"deepseek-harness",
|
|
12
|
+
"agent-memory",
|
|
13
|
+
"self-learning",
|
|
14
|
+
"ai",
|
|
15
|
+
"agent",
|
|
16
|
+
"cordis",
|
|
17
|
+
"plugin"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Aatzer0",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Aatzer0/dsh-experience.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/Aatzer0/dsh-experience#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/Aatzer0/dsh-experience/issues"
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "index.js",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.js",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"index.js",
|
|
40
|
+
"lib",
|
|
41
|
+
"tools/selftest.mjs",
|
|
42
|
+
"tools/e2e.mjs",
|
|
43
|
+
"cordis.patch.yml",
|
|
44
|
+
"README.md",
|
|
45
|
+
"USER-GUIDE.md",
|
|
46
|
+
"CONTRIBUTING.md",
|
|
47
|
+
"CHANGELOG.md",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"store.example.json",
|
|
50
|
+
"config.example.json",
|
|
51
|
+
"package.json"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "node tools/selftest.mjs",
|
|
55
|
+
"test:e2e": "node tools/e2e.mjs"
|
|
56
|
+
},
|
|
57
|
+
"dsh": {
|
|
58
|
+
"bundle": {
|
|
59
|
+
"patch": "./cordis.patch.yml"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|