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/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
+ }
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 2,
3
+ "nextId": 1,
4
+ "nextInjectionId": 1,
5
+ "experiences": [],
6
+ "injections": [],
7
+ "turns": [],
8
+ "archive": [],
9
+ "evictions": []
10
+ }