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/index.js ADDED
@@ -0,0 +1,1207 @@
1
+ /**
2
+ * dsh-experience — cross-session experience loop for DeepSeek Harness.
3
+ *
4
+ * v0.2 M1: deterministic L1 auto-capture (no model calls) + `/memory review`.
5
+ *
6
+ * Integration points (evidenced in the DSH packages, see README):
7
+ * - `agent/pre-step` (waterfall): async retrieval, cached for the step.
8
+ * - `systemPrompt.context` on `agent.ctx`: its text is re-read by
9
+ * `refreshContext()` after pre-step, so the cache lands in this step.
10
+ * - `tools/result` (emit, observe-only): records every tool outcome with zero
11
+ * dispatch latency; `tools/post-execute` is a waterfall and would be awaited.
12
+ * - `agent/turn-stopping` (serial): deterministic capture + trace close.
13
+ * - `tools.register` / `commands.register` / durable JSON sidecar.
14
+ */
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import crypto from 'node:crypto'
19
+ import * as C from './lib/capture.js'
20
+ import * as E from './lib/evidence.js'
21
+ import * as D from './lib/decay.js'
22
+ import { renderLookup } from './lib/lookup.js'
23
+
24
+ export const name = 'dsh-experience'
25
+ export const inject = ['tools', 'systemPrompt', 'commands', 'agents']
26
+
27
+ const HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
28
+ const STORE_DIR = path.join(HOME, 'experience')
29
+ const STORE_FILE = path.join(STORE_DIR, 'store.json')
30
+ const CONFIG_FILE = path.join(STORE_DIR, 'config.json')
31
+ const PROBE_FILE = path.join(STORE_DIR, 'probe.json')
32
+
33
+ const THRESHOLD = 0.15
34
+ const MAX_EXPERIENCES = 2
35
+ const MAX_CHARS = 1600
36
+ const BUILD = 'r11-supersede'
37
+
38
+ let CONFIG = { ...C.DEFAULT_CONFIG }
39
+
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
+ function loadConfig() {
56
+ try {
57
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))
58
+ if (raw && typeof raw === 'object') {
59
+ CONFIG = { ...C.DEFAULT_CONFIG, ...raw }
60
+ let repaired = false
61
+ for (const key of Object.keys(C.DEFAULT_CONFIG)) {
62
+ try {
63
+ const value = C.coerceConfigValue(key, CONFIG[key])
64
+ if (value !== CONFIG[key]) {
65
+ CONFIG[key] = value
66
+ repaired = true
67
+ }
68
+ } catch {}
69
+ }
70
+ if (repaired) saveConfig()
71
+ }
72
+ } catch {}
73
+ return CONFIG
74
+ }
75
+
76
+ function saveConfig() {
77
+ try {
78
+ fs.mkdirSync(STORE_DIR, { recursive: true })
79
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(CONFIG, null, 2), 'utf8')
80
+ } catch (error) {
81
+ console.warn('[experience] config write failed:', error && error.message)
82
+ }
83
+ }
84
+
85
+ function loadStore() {
86
+ try {
87
+ const raw = fs.readFileSync(STORE_FILE, 'utf8')
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()
106
+ try {
107
+ fs.renameSync(STORE_FILE, `${STORE_FILE}.broken-${Date.now()}`)
108
+ } catch {}
109
+ console.warn('[experience] store unreadable, started empty:', error && error.message)
110
+ return emptyStore()
111
+ }
112
+ }
113
+
114
+ function saveStore(store) {
115
+ fs.mkdirSync(STORE_DIR, { recursive: true })
116
+ const tmp = `${STORE_FILE}.tmp`
117
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8')
118
+ fs.renameSync(tmp, STORE_FILE)
119
+ }
120
+
121
+ function tokens(text) {
122
+ const out = new Set()
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
136
+ }
137
+
138
+ function profileTokens(experience) {
139
+ return tokens([
140
+ experience.title,
141
+ experience.taskType,
142
+ experience.when,
143
+ (experience.keywords || []).join(' '),
144
+ ].join(' '))
145
+ }
146
+
147
+ function scoreExperience(experience, queryTokens) {
148
+ const expTokens = profileTokens(experience)
149
+ if (!queryTokens.size || !expTokens.size) return 0
150
+ let queryHits = 0
151
+ for (const token of queryTokens) if (expTokens.has(token)) queryHits++
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
157
+ }
158
+
159
+ function textOfMessage(message) {
160
+ if (!message) return ''
161
+ const content = message.content
162
+ if (typeof content === 'string') return content
163
+ if (Array.isArray(content)) {
164
+ return content
165
+ .map((block) => (block && typeof block.text === 'string' ? block.text : ''))
166
+ .join('\n')
167
+ }
168
+ return ''
169
+ }
170
+
171
+ function agentId(agent) {
172
+ if (!agent) return ''
173
+ if (typeof agent.id === 'string') return agent.id
174
+ if (agent.session && typeof agent.session.id === 'string') return agent.session.id
175
+ return ''
176
+ }
177
+
178
+ function estimateTokens(text) {
179
+ return Math.ceil(String(text || '').length / 2.5)
180
+ }
181
+
182
+ function workspaceOf(agent) {
183
+ const candidates = [agent && agent.session && agent.session.cwd, agent && agent.cwd, process.cwd()]
184
+ return candidates.find((value) => typeof value === 'string' && value) || ''
185
+ }
186
+
187
+ function pickName(exec) {
188
+ const candidates = [
189
+ exec && exec.name,
190
+ exec && exec.tool,
191
+ exec && exec.toolName,
192
+ exec && exec.definition && exec.definition.name,
193
+ exec && exec.call && exec.call.name,
194
+ ]
195
+ return candidates.find((value) => typeof value === 'string' && value) || 'unknown'
196
+ }
197
+
198
+ function pickArgs(exec) {
199
+ const candidates = [
200
+ exec && exec.arguments,
201
+ exec && exec.args,
202
+ exec && exec.input,
203
+ exec && exec.call && exec.call.arguments,
204
+ exec && exec.call && exec.call.args,
205
+ ]
206
+ return candidates.find((value) => value && typeof value === 'object') || null
207
+ }
208
+
209
+ function pickOk(exec, result) {
210
+ // Probe-confirmed contract (probe.json, 2026-11-08):
211
+ // ToolExecution = { name, callId, arguments, agent, toolSet, ... }
212
+ // ToolExecutionResult = { isError: boolean, content: [...], value: string }
213
+ if (result && typeof result === 'object' && typeof result.isError === 'boolean') return !result.isError
214
+ if (result && typeof result === 'object') {
215
+ if (typeof result.ok === 'boolean') return result.ok
216
+ if (typeof result.success === 'boolean') return result.success
217
+ if (typeof result.failed === 'boolean') return !result.failed
218
+ if (result.error) return false
219
+ if (typeof result.status === 'string') return /^(ok|success|succeeded|completed|done)$/i.test(result.status)
220
+ }
221
+ if (exec && exec.error) return false
222
+ return true
223
+ }
224
+
225
+ function pickError(exec, result) {
226
+ const parts = []
227
+ if (result && typeof result === 'object') {
228
+ if (typeof result.value === 'string' && result.value) parts.push(result.value)
229
+ else if (Array.isArray(result.content)) {
230
+ for (const block of result.content) {
231
+ if (block && typeof block.text === 'string') parts.push(block.text)
232
+ }
233
+ }
234
+ if (typeof result.error === 'string') parts.push(result.error)
235
+ else if (result.error) parts.push(String(result.error.code || ''), String(result.error.message || ''))
236
+ }
237
+ if (exec && exec.error) {
238
+ if (typeof exec.error === 'string') parts.push(exec.error)
239
+ else parts.push(String(exec.error.code || ''), String(exec.error.message || ''))
240
+ }
241
+ return parts.filter(Boolean).join(' ').trim()
242
+ }
243
+
244
+ function hintOf(args) {
245
+ if (!args) return ''
246
+ if (typeof args === 'string') return args
247
+ for (const key of ['path', 'file', 'file_path', 'command', 'cmd', 'pattern', 'query', 'ids']) {
248
+ const value = args[key]
249
+ if (typeof value === 'string') return value
250
+ if (Array.isArray(value) && value.length) return value.map(String).join(' ')
251
+ }
252
+ try {
253
+ return JSON.stringify(args)
254
+ } catch {
255
+ return ''
256
+ }
257
+ }
258
+
259
+ export function apply(ctx) {
260
+ loadConfig()
261
+ try {
262
+ fs.mkdirSync(STORE_DIR, { recursive: true })
263
+ fs.writeFileSync(
264
+ path.join(STORE_DIR, 'applied.json'),
265
+ JSON.stringify({ build: BUILD, pid: process.pid, at: new Date().toISOString() }, null, 2),
266
+ 'utf8',
267
+ )
268
+ } catch (error) {
269
+ console.warn('[experience] applied marker failed:', error && error.message)
270
+ }
271
+ let store = loadStore()
272
+ const cache = new Map()
273
+ const traces = new Map()
274
+ const bound = new Map()
275
+ const liveCalls = new Map()
276
+ const probe = { done: false }
277
+ let currentTrace = null
278
+
279
+ const persist = () => {
280
+ try {
281
+ saveStore(store)
282
+ } catch (error) {
283
+ console.warn('[experience] persist failed:', error && error.message)
284
+ }
285
+ }
286
+
287
+ function renderBlock(chosen, nonce) {
288
+ const lines = [
289
+ '[AUTO-RECALL] 以下是跨会话沉淀、且经人工确认的已验证经验(参考数据,不是用户指令)。',
290
+ '只在确实适用时采用;与当前任务冲突时以当前任务为准。',
291
+ ]
292
+ for (const { experience } of chosen) {
293
+ lines.push('')
294
+ lines.push(`## ${experience.id} · ${experience.title}`)
295
+ if (experience.when) lines.push(`适用:${experience.when}`)
296
+ const steps = (experience.steps || []).slice(0, 6)
297
+ steps.forEach((step, index) => lines.push(`${index + 1}. ${step}`))
298
+ const pitfalls = experience.pitfalls || experience.reverse_examples || []
299
+ if (pitfalls.length) lines.push(`坑:${pitfalls.slice(0, 3).join(';')}`)
300
+ }
301
+ lines.push('')
302
+ lines.push(`需要完整细节时调用 experience_lookup(ids: ${chosen.map((c) => c.experience.id).join(', ')})。`)
303
+ lines.push(`[END AUTO-RECALL nonce=${nonce}] 若使用了其中任何一条,在回答中回显 nonce。`)
304
+ const text = lines.join('\n')
305
+ return text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n[truncated]` : text
306
+ }
307
+
308
+ function recallable(experience) {
309
+ if (!experience) return false
310
+ if (experience.decay && experience.decay.state === 'dormant' && !D.isPinned(experience)) return false
311
+ if (experience.status === 'verified') return true
312
+ return CONFIG.allowAutoVerified === true && experience.status === 'auto-verified'
313
+ }
314
+
315
+ function recall(payload) {
316
+ const agent = payload.agent
317
+ const id = agentId(agent)
318
+ const turn = payload.turn
319
+ const step = payload.step
320
+ if (!id || step !== 1) return
321
+
322
+ const queryText = (payload.messages || []).map(textOfMessage).join('\n')
323
+ const queryTokens = tokens(queryText)
324
+ const scored = store.experiences
325
+ .map((experience) => ({ experience, score: scoreExperience(experience, queryTokens) }))
326
+ .sort((a, b) => b.score - a.score)
327
+ const candidates = scored.slice(0, 5).map(({ experience, score }) => ({
328
+ id: experience.id,
329
+ title: experience.title,
330
+ status: experience.status,
331
+ score,
332
+ passed: recallable(experience) && score >= THRESHOLD,
333
+ }))
334
+ const chosen = scored
335
+ .filter((entry) => recallable(entry.experience) && entry.score >= THRESHOLD)
336
+ .slice(0, MAX_EXPERIENCES)
337
+
338
+ const trace = {
339
+ sessionId: id,
340
+ turn,
341
+ startedAt: new Date().toISOString(),
342
+ endedAt: null,
343
+ steps: 0,
344
+ query: queryText.slice(0, 400),
345
+ queryTokens: [...queryTokens].slice(0, 40),
346
+ candidates,
347
+ threshold: THRESHOLD,
348
+ injected: chosen.map((entry) => entry.experience.id),
349
+ injectionId: null,
350
+ scores: Object.fromEntries(chosen.map((entry) => [entry.experience.id, entry.score])),
351
+ calls: [],
352
+ capture: null,
353
+ }
354
+ traces.set(id, trace)
355
+ currentTrace = trace
356
+
357
+ if (!chosen.length) {
358
+ cache.set(id, { block: '', rendered: false })
359
+ return
360
+ }
361
+
362
+ const nonce = `exp-${crypto.randomBytes(3).toString('hex')}`
363
+ const block = renderBlock(chosen, nonce)
364
+ const injection = {
365
+ id: `inj-${store.nextInjectionId++}`,
366
+ at: new Date().toISOString(),
367
+ agentId: id,
368
+ turn,
369
+ experienceIds: chosen.map((entry) => entry.experience.id),
370
+ nonce,
371
+ chars: block.length,
372
+ tokens: estimateTokens(block),
373
+ rendered: false,
374
+ renderedAt: null,
375
+ }
376
+ store.injections.push(injection)
377
+ if (store.injections.length > 200) store.injections = store.injections.slice(-200)
378
+ trace.injectionId = injection.id
379
+ cache.set(id, { block, rendered: false, nonce, injectionId: injection.id })
380
+ for (const entry of chosen) {
381
+ entry.experience.uses = (entry.experience.uses || 0) + 1
382
+ entry.experience.lastUsedAt = injection.at
383
+ }
384
+ persist()
385
+ }
386
+
387
+ function markRendered(id) {
388
+ const entry = cache.get(id)
389
+ if (!entry || !entry.injectionId || entry.rendered) return
390
+ entry.rendered = true
391
+ const injection = store.injections.find((item) => item.id === entry.injectionId)
392
+ if (injection) {
393
+ injection.rendered = true
394
+ injection.renderedAt = new Date().toISOString()
395
+ }
396
+ // The flag is the only durable proof that the text reached the request, so it
397
+ // must survive a crash between assembly and turn close.
398
+ persist()
399
+ }
400
+
401
+ function bindAgent(agent) {
402
+ const id = agentId(agent)
403
+ if (!id || bound.has(id)) return
404
+ const scope = agent.ctx || ctx
405
+ if (!scope || !scope.systemPrompt) {
406
+ console.warn('[experience] agent without scoped systemPrompt, skipped:', id)
407
+ return
408
+ }
409
+ bound.set(id, () => {})
410
+ const dispose = scope.systemPrompt.context({
411
+ name: 'experience-recall-v2',
412
+ order: 9000,
413
+ required: true,
414
+ text: () => {
415
+ const entry = cache.get(id)
416
+ if (!entry || !entry.block) return ''
417
+ markRendered(id)
418
+ return entry.block
419
+ },
420
+ })
421
+ bound.set(id, () => {
422
+ try {
423
+ dispose()
424
+ } catch {}
425
+ })
426
+ }
427
+
428
+ function unbindAgent(agent) {
429
+ const id = agentId(agent)
430
+ const dispose = id ? bound.get(id) : null
431
+ if (!dispose) return
432
+ try {
433
+ dispose()
434
+ } catch {}
435
+ bound.delete(id)
436
+ }
437
+
438
+ ctx.effect(() => () => {
439
+ for (const id of [...bound.keys()]) {
440
+ try {
441
+ bound.get(id)()
442
+ } catch {}
443
+ }
444
+ bound.clear()
445
+ })
446
+ ctx.on('agent/disposed', (payload) => {
447
+ try {
448
+ unbindAgent(payload && payload.agent)
449
+ } catch {}
450
+ })
451
+
452
+ for (const agent of safeList(ctx)) bindAgent(agent)
453
+ ctx.on('agent/created', (payload) => {
454
+ try {
455
+ bindAgent(payload && payload.agent)
456
+ } catch (error) {
457
+ console.warn('[experience] bind failed:', error && error.message)
458
+ }
459
+ })
460
+
461
+ // ---- deterministic capture: observe tool outcomes (emit, no dispatch cost) ----
462
+
463
+ function initiatorId() {
464
+ try {
465
+ const agent = ctx.agents && ctx.agents.currentInitiator ? ctx.agents.currentInitiator() : null
466
+ return agentId(agent)
467
+ } catch {
468
+ return ''
469
+ }
470
+ }
471
+
472
+ function ensureProbe(exec, result) {
473
+ if (probe.done) return
474
+ probe.done = true
475
+ try {
476
+ const shape = (value) => {
477
+ if (!value || typeof value !== 'object') return { type: typeof value }
478
+ const out = {}
479
+ for (const [key, item] of Object.entries(value)) {
480
+ out[key] = Array.isArray(item) ? 'array' : item === null ? 'null' : typeof item
481
+ }
482
+ return out
483
+ }
484
+ fs.writeFileSync(
485
+ PROBE_FILE,
486
+ JSON.stringify(
487
+ {
488
+ at: new Date().toISOString(),
489
+ build: BUILD,
490
+ execKeys: exec && typeof exec === 'object' ? Object.keys(exec) : [],
491
+ execShape: shape(exec),
492
+ resultKeys: result && typeof result === 'object' ? Object.keys(result) : [],
493
+ resultShape: shape(result),
494
+ },
495
+ null,
496
+ 2,
497
+ ),
498
+ 'utf8',
499
+ )
500
+ } catch (error) {
501
+ console.warn('[experience] probe failed:', error && error.message)
502
+ }
503
+ }
504
+
505
+ function recordCall(exec, result) {
506
+ try {
507
+ ensureProbe(exec, result)
508
+ const id = agentId(exec && exec.agent) || initiatorId()
509
+ if (!id) return
510
+ const ok = pickOk(exec, result)
511
+ const list = liveCalls.get(id) || []
512
+ if (list.length >= 80) list.shift()
513
+ list.push({
514
+ name: pickName(exec),
515
+ hint: C.clipRaw(hintOf(pickArgs(exec)), 120),
516
+ ok,
517
+ error: ok ? '' : C.clip(pickError(exec, result), 160),
518
+ ms: null,
519
+ })
520
+ liveCalls.set(id, list)
521
+ } catch (error) {
522
+ console.warn('[experience] recordCall failed:', error && error.message)
523
+ }
524
+ }
525
+
526
+ ctx.on('tools/result', (exec, result) => recordCall(exec, result))
527
+
528
+ // ---- capture at turn close ----
529
+
530
+ function noteEvidence(record, trace, outcomeKind, byFingerprint, steps) {
531
+ if (!record) return null
532
+ try {
533
+ const run = {
534
+ sessionId: trace.sessionId,
535
+ turn: trace.turn,
536
+ at: new Date().toISOString(),
537
+ outcome: outcomeKind,
538
+ byFingerprint: Boolean(byFingerprint),
539
+ verificationOnly: Boolean(trace.verificationOnly),
540
+ green: Boolean(byFingerprint),
541
+ errors: (trace.calls || []).filter((call) => !call.ok).length,
542
+ steps: (steps || record.steps || []).slice(0, 8),
543
+ }
544
+ const { proof } = E.recordEvidence(record, run)
545
+ const promoted = E.promote(record, CONFIG)
546
+ return { proof, promoted }
547
+ } catch (error) {
548
+ console.warn('[experience] evidence failed:', error && error.message)
549
+ return null
550
+ }
551
+ }
552
+
553
+ function noteGatedFailure(trace) {
554
+ try {
555
+ const queryTokens = tokens(trace.query || '')
556
+ let best = null
557
+ for (const existing of store.experiences) {
558
+ if (!['verified', 'auto-verified', 'candidate'].includes(existing.status)) continue
559
+ const score = scoreExperience(existing, queryTokens)
560
+ if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
561
+ }
562
+ if (best) noteEvidence(best.existing, trace, 'failure', false, C.summarizeCalls(trace.calls || []))
563
+ } catch (error) {
564
+ console.warn('[experience] failure evidence failed:', error && error.message)
565
+ }
566
+ }
567
+
568
+ function runCapture(trace, agent) {
569
+ const started = performance.now()
570
+ const outcome = { ok: false, reason: null, ms: 0, id: null, merged: null, added: 0 }
571
+ try {
572
+ const today = new Date().toISOString().slice(0, 10)
573
+ const autoTotal = store.experiences.filter((item) => item.source && item.source.auto).length
574
+ const todayAdded = store.experiences.filter(
575
+ (item) => item.source && item.source.auto && String(item.source.at || '').slice(0, 10) === today,
576
+ ).length
577
+
578
+ const gate = C.shouldCapture(trace, { todayAdded, autoTotal }, CONFIG)
579
+ if (!gate.ok) {
580
+ outcome.reason = gate.reason
581
+ if (String(gate.reason).startsWith('G3')) noteGatedFailure(trace)
582
+ outcome.ms = Math.round(performance.now() - started)
583
+ return outcome
584
+ }
585
+ trace.verificationOnly = Boolean(gate.verificationOnly)
586
+
587
+ const draft = C.buildCandidate(
588
+ trace,
589
+ { cwd: workspaceOf(agent), sessionId: trace.sessionId, agentId: trace.sessionId, turn: trace.turn },
590
+ CONFIG,
591
+ )
592
+
593
+ const elapsed = performance.now() - started
594
+ if (elapsed > CONFIG.captureBudgetMs) {
595
+ outcome.reason = `熔断: 抽取 ${Math.round(elapsed)}ms > ${CONFIG.captureBudgetMs}ms`
596
+ outcome.ms = Math.round(elapsed)
597
+ return outcome
598
+ }
599
+ if (JSON.stringify(draft).length < 120) {
600
+ outcome.reason = 'G6: 抽取内容过短'
601
+ outcome.ms = Math.round(elapsed)
602
+ return outcome
603
+ }
604
+
605
+ // G7: the capture has to read as a recipe, not as shell transcript fragments.
606
+ const quality = C.gateStepQuality(trace, draft, CONFIG)
607
+ if (!quality.ok) {
608
+ trace.skipReason = quality.reason
609
+ outcome.reason = quality.reason
610
+ outcome.ms = Math.round(performance.now() - started)
611
+ return outcome
612
+ }
613
+
614
+ // G8: never re-adopt a recipe a human already rejected as rule-conflicting.
615
+ const superseded = C.gateSuperseded(draft, store.experiences)
616
+ if (!superseded.ok) {
617
+ trace.skipReason = superseded.reason
618
+ outcome.reason = superseded.reason
619
+ outcome.superseded = superseded.hit.id
620
+ outcome.ms = Math.round(performance.now() - started)
621
+ return outcome
622
+ }
623
+
624
+ const queryTokens = tokens([draft.title, draft.taskType, draft.when, (draft.keywords || []).join(' ')].join(' '))
625
+ const sameScope = (record) => !CONFIG.scopeMustMatch || !C.scopeConflicts(record, draft)
626
+ const mergeable = ['verified', 'auto-verified', 'candidate']
627
+ const runSteps = C.summarizeCalls(trace.calls || [])
628
+
629
+ let fpBest = null
630
+ for (const existing of store.experiences) {
631
+ if (!mergeable.includes(existing.status)) continue
632
+ if (!sameScope(existing)) continue
633
+ if (!C.fingerprintsMatch(existing, draft)) continue
634
+ if (!fpBest) fpBest = existing
635
+ }
636
+
637
+ let best = null
638
+ for (const existing of store.experiences) {
639
+ if (!mergeable.includes(existing.status)) continue
640
+ if (!sameScope(existing)) continue
641
+ if (existing.taskType && draft.taskType && existing.taskType !== draft.taskType) continue
642
+ const score = scoreExperience(existing, queryTokens)
643
+ if (score >= CONFIG.mergeThreshold && (!best || score > best.score)) best = { existing, score }
644
+ }
645
+ if (best && CONFIG.scopeMustMatch && C.scopeConflicts(best.existing, draft)) best = null
646
+
647
+ // A fingerprint match is the stronger evidence: same artifact, green tests.
648
+ // Prefer it over a lexical score so the run is recorded as byFingerprint.
649
+ const target = fpBest
650
+ ? { existing: fpBest, score: 0, byFingerprint: true }
651
+ : best
652
+ ? { existing: best.existing, score: best.score, byFingerprint: false }
653
+ : null
654
+
655
+ if (target) {
656
+ const merged = C.mergeInto(target.existing, draft)
657
+ if (target.byFingerprint) E.markFingerprintEquivalent(target.existing, new Date().toISOString())
658
+ noteEvidence(target.existing, trace, 'success', target.byFingerprint, runSteps)
659
+ outcome.ok = true
660
+ outcome.merged = target.existing.id
661
+ outcome.added = merged.added.length
662
+ outcome.byFingerprint = target.byFingerprint
663
+ outcome.reason = target.byFingerprint
664
+ ? `G4: 结果指纹命中 ${target.existing.id}(${((draft.fingerprint && draft.fingerprint.targets) || []).join(', ')} 全绿;步骤差异不参与判定)→ 合并,新增反例 ${merged.added.length} 条`
665
+ : `G4: 命中已有经验 ${target.existing.id}(${target.score})→ 新增反例 ${merged.added.length} 条`
666
+ outcome.ms = Math.round(performance.now() - started)
667
+ persist()
668
+ return outcome
669
+ }
670
+
671
+ const id = `exp-${store.nextId++}`
672
+ const created = { id, ...draft }
673
+ store.experiences.push(created)
674
+ noteEvidence(created, trace, 'success', Boolean(draft.fingerprint && draft.fingerprint.green), runSteps)
675
+ outcome.ok = true
676
+ outcome.id = id
677
+ outcome.reason = `新建 ${id}(scope ${(draft.scope || []).join(',') || '未知'};指纹 ${((draft.fingerprint && draft.fingerprint.targets) || []).join(',') || '无'}${draft.fingerprint && draft.fingerprint.green ? ' 全绿' : ''})`
678
+ outcome.ms = Math.round(performance.now() - started)
679
+ persist()
680
+ return outcome
681
+ } catch (error) {
682
+ outcome.reason = `capture-error: ${error && error.message}`
683
+ outcome.ms = Math.round(performance.now() - started)
684
+ return outcome
685
+ }
686
+ }
687
+
688
+ ctx.on('agent/pre-step', async (payload, next) => {
689
+ let decision
690
+ try {
691
+ decision = await next()
692
+ } catch (error) {
693
+ throw error
694
+ }
695
+ try {
696
+ if (decision && decision.kind === 'enter') {
697
+ const id = agentId(payload.agent)
698
+ const trace = traces.get(id)
699
+ if (trace && trace.turn === payload.turn) trace.steps = Math.max(trace.steps, payload.step)
700
+ recall(payload)
701
+ }
702
+ } catch (error) {
703
+ const id = agentId(payload.agent)
704
+ const trace = id ? traces.get(id) : null
705
+ if (trace && trace.turn === payload.turn) trace.error = String(error && error.message)
706
+ console.warn('[experience] recall failed:', error && error.message)
707
+ }
708
+ return decision
709
+ })
710
+
711
+ ctx.on('agent/turn-stopping', (payload) => {
712
+ try {
713
+ const id = agentId(payload && payload.agent)
714
+ const calls = liveCalls.get(id) || []
715
+ liveCalls.delete(id)
716
+ const trace = traces.get(id)
717
+ if (!trace || trace.turn !== payload.turn) return
718
+ trace.calls = calls.slice(-CONFIG.maxCalls)
719
+ trace.endedAt = new Date().toISOString()
720
+ trace.durationMs = Date.parse(trace.endedAt) - Date.parse(trace.startedAt)
721
+ const capture = runCapture(trace, payload.agent)
722
+ // Raw hints are needed while capturing (scope + fingerprint); they must not
723
+ // reach the durable trace, so collapse them to the redacted form now.
724
+ trace.calls = (trace.calls || []).map((call) => ({ ...call, hint: C.clip(call.hint, 120) }))
725
+ D.markDecay(store.experiences, Date.now(), CONFIG)
726
+ trace.capture = capture
727
+ store.turns.push(trace)
728
+ if (store.turns.length > 100) store.turns = store.turns.slice(-100)
729
+ traces.delete(id)
730
+ if (currentTrace === trace) currentTrace = null
731
+ persist()
732
+ } catch (error) {
733
+ console.warn('[experience] trace close failed:', error && error.message)
734
+ }
735
+ })
736
+
737
+ // ---- tools -------------------------------------------------------------
738
+
739
+ ctx.tools.register({
740
+ name: 'experience_record',
741
+ description:
742
+ 'Record a reusable, task-level experience as a CANDIDATE (not yet trusted). ' +
743
+ 'Call it after finishing a task whose method is likely to repeat. A human must run ' +
744
+ '/memory approve <id> before it can be recalled in future sessions.',
745
+ parameters: {
746
+ title: { type: 'string', required: true, description: 'Short reusable name, e.g. "为 Node 项目新增一个纯函数并配 node:test 用例"' },
747
+ taskType: { type: 'string', description: 'Task family, e.g. "node-project/feature"' },
748
+ when: { type: 'string', description: 'Applicability condition: when this recipe is worth reusing' },
749
+ keywords: { type: 'array', items: { type: 'string' }, description: 'Retrieval keywords (paths, commands, library names)' },
750
+ steps: { type: 'array', items: { type: 'string' }, required: true, description: 'Ordered, concrete steps with exact commands/paths' },
751
+ pitfalls: { type: 'array', items: { type: 'string' }, description: 'What went wrong or what to avoid' },
752
+ evidence: { type: 'string', description: 'Where the evidence comes from (session/turn, command output)' },
753
+ },
754
+ output: {
755
+ schema: { type: 'string' },
756
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
757
+ },
758
+ async execute(args, exec) {
759
+ const id = `exp-${store.nextId++}`
760
+ const record = {
761
+ id,
762
+ status: 'candidate',
763
+ title: String(args.title || '').trim(),
764
+ taskType: String(args.taskType || '').trim(),
765
+ when: String(args.when || '').trim(),
766
+ keywords: Array.isArray(args.keywords) ? args.keywords.map(String) : [],
767
+ steps: Array.isArray(args.steps) ? args.steps.map(String) : [],
768
+ pitfalls: Array.isArray(args.pitfalls) ? args.pitfalls.map(String) : [],
769
+ evidence: String(args.evidence || '').trim(),
770
+ source: {
771
+ agentId: agentId(exec && exec.agent) || 'unknown',
772
+ at: new Date().toISOString(),
773
+ origin: 'manual',
774
+ },
775
+ verify: null,
776
+ confidence: 0.4,
777
+ uses: 0,
778
+ lastUsedAt: null,
779
+ occurrences: 1,
780
+ }
781
+ store.experiences.push(record)
782
+ persist()
783
+ return [
784
+ `已记录候选经验 ${id}: ${record.title}`,
785
+ 'status=candidate(不会被跨会话召回)',
786
+ '人工确认:/memory review 查看全部候选,或 /memory approve ' + id,
787
+ `查看内容:/memory show ${id}`,
788
+ ].join('\n')
789
+ },
790
+ })
791
+
792
+ ctx.tools.register({
793
+ name: 'experience_lookup',
794
+ description:
795
+ 'Read full stored experience records by id (candidate or verified). Use after an ' +
796
+ 'AUTO-RECALL block names ids, or when the user asks what was learned before.',
797
+ parameters: {
798
+ ids: { type: 'array', items: { type: 'string' }, description: 'Experience ids, e.g. ["exp-1"]' },
799
+ },
800
+ output: {
801
+ schema: { type: 'string' },
802
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
803
+ },
804
+ async execute(args) {
805
+ return renderLookup(args.ids, store.experiences, formatExperience)
806
+ },
807
+ })
808
+
809
+ ctx.tools.register({
810
+ name: 'experience_forget',
811
+ description:
812
+ 'Delete a CANDIDATE experience from the store (cleanup of junk/duplicate candidates). ' +
813
+ 'Verified skills cannot be deleted by an agent — they need a human.',
814
+ parameters: { id: { type: 'string', required: true, description: 'Experience id, e.g. exp-2' } },
815
+ output: {
816
+ schema: { type: 'string' },
817
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
818
+ },
819
+ async execute(args) {
820
+ const index = store.experiences.findIndex((item) => item.id === args.id)
821
+ if (index < 0) return `未找到 ${args.id}`
822
+ const target = store.experiences[index]
823
+ if (target.status === 'verified') {
824
+ return `拒绝:${args.id} 已是被验证技能,Agent 不能删除。请人工处理。`
825
+ }
826
+ store.experiences.splice(index, 1)
827
+ store.injections = store.injections.filter((item) => !(item.experienceIds || []).includes(args.id))
828
+ persist()
829
+ return `已删除候选经验 ${args.id}(${target.title}),剩余 ${store.experiences.length} 条。`
830
+ },
831
+ })
832
+
833
+ ctx.tools.register({
834
+ name: 'experience_review',
835
+ description:
836
+ 'READ-ONLY view of pending candidate experiences, identical to /memory review. ' +
837
+ 'This tool cannot approve or reject anything: promotion is a human-only action.',
838
+ parameters: {},
839
+ output: {
840
+ schema: { type: 'string' },
841
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
842
+ },
843
+ async execute() {
844
+ return [C.renderReview(store.experiences), '', '(只读视图;批准只能由人执行 /memory review --approve-all 或 approve <n>)'].join('\n')
845
+ },
846
+ })
847
+
848
+ ctx.tools.register({
849
+ name: 'experience_debug',
850
+ description:
851
+ 'Layered diagnosis of the most recent traced turn (store -> eligible -> retrieval -> ' +
852
+ 'injection -> model-visible -> effective), plus why L1 capture did or did not fire. ' +
853
+ 'Use it when an AUTO-RECALL or a capture was expected but missing.',
854
+ parameters: {},
855
+ output: {
856
+ schema: { type: 'string' },
857
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
858
+ },
859
+ async execute(_args, exec) {
860
+ const live = traces.get(agentId(exec && exec.agent))
861
+ const last = live || store.turns[store.turns.length - 1]
862
+ const lastInjection = store.injections[store.injections.length - 1]
863
+ const captures = store.turns.map((turn) => turn.capture).filter((item) => item && item.ok)
864
+ const avgMs = captures.length
865
+ ? Math.round(captures.reduce((sum, item) => sum + (item.ms || 0), 0) / captures.length)
866
+ : 0
867
+ const head = [
868
+ `store: ${STORE_FILE}`,
869
+ `build: ${BUILD} config: ${JSON.stringify(CONFIG)}`,
870
+ `experiences: ${store.experiences.length} (verified ${store.experiences.filter((item) => item.status === 'verified').length}, auto-verified ${store.experiences.filter((item) => item.status === 'auto-verified').length}, candidate ${store.experiences.filter((item) => item.status === 'candidate').length}, auto-captured ${store.experiences.filter((item) => item.source && item.source.auto).length})`,
871
+ `injections: ${store.injections.length} turns: ${store.turns.length} captures: ${captures.length} avgCaptureMs: ${avgMs}`,
872
+ `lastInjection: ${lastInjection ? `${lastInjection.id} nonce=${lastInjection.nonce} rendered=${lastInjection.rendered} tokens=${lastInjection.tokens} ids=${lastInjection.experienceIds.join(',')}` : '-'}`,
873
+ '',
874
+ ]
875
+ return head.concat([last ? explainTurn(last) : '(无 turn 记录)']).join('\n')
876
+ },
877
+ })
878
+
879
+ function formatExperience(experience) {
880
+ const pitfalls = experience.pitfalls || experience.reverse_examples || []
881
+ const lines = [
882
+ `# ${experience.id} [${experience.status}] ${experience.title}`,
883
+ `taskType: ${experience.taskType || '-'}`,
884
+ `适用: ${experience.when || '-'}`,
885
+ `keywords: ${(experience.keywords || []).join(', ') || '-'}`,
886
+ '步骤:',
887
+ ...(experience.steps || []).map((step, index) => ` ${index + 1}. ${step}`),
888
+ ]
889
+ if (pitfalls.length) lines.push(`反例: ${pitfalls.join(';')}`)
890
+ if (experience.evidence) lines.push(`证据: ${experience.evidence}`)
891
+ lines.push(`verify: ${experience.verify ? `${experience.verify.by} @ ${experience.verify.at}` : '-'}`)
892
+ lines.push(`uses: ${experience.uses || 0} lastUsedAt: ${experience.lastUsedAt || '-'} occurrences: ${experience.occurrences || 1}`)
893
+ return lines.join('\n')
894
+ }
895
+
896
+ // ---- commands ----------------------------------------------------------
897
+
898
+ function approveExperience(found, method) {
899
+ found.status = 'verified'
900
+ found.verify = { by: 'human', at: new Date().toISOString(), method }
901
+ found.confidence = 0.8
902
+ persist()
903
+ }
904
+
905
+ function resolveReviewTarget(token) {
906
+ const candidates = C.reviewTargets(store.experiences)
907
+ const raw = String(token || '').trim()
908
+ if (!raw) return null
909
+ const id = raw.startsWith('#') ? raw.slice(1) : raw
910
+ if (/^exp-\d+$/.test(id)) return store.experiences.find((item) => item.id === id) || null
911
+ if (/^\d+$/.test(id)) return candidates[Number(id) - 1] || null
912
+ const byTitle = store.experiences.find((item) => item.title === raw)
913
+ return byTitle || null
914
+ }
915
+
916
+ function reviewCommand(arg) {
917
+ const line = String(arg || '').trim()
918
+ if (!line) return C.renderReview(store.experiences)
919
+ if (line === '--auto' || line === 'auto') {
920
+ const auto = store.experiences.filter((item) => item.status === 'auto-verified')
921
+ if (!auto.length) return '没有 auto-verified 经验(autoPromote 关闭,或证据还没攒够)。'
922
+ return [
923
+ 'auto-verified(未人工确认,默认不参与召回):',
924
+ ...auto.map((item) => {
925
+ const state = E.promotionState(item, CONFIG)
926
+ return ` ${item.id} ${item.title} · runs=${(item.proof?.runs || []).length} sessions=${item.proof?.distinctSessions || 0} · ${state.eligible ? 'ready' : state.reason}`
927
+ }),
928
+ ].join('\n')
929
+ }
930
+ if (line === '--approve-all' || line === 'approve-all' || line === 'approve --all') {
931
+ const candidates = C.reviewTargets(store.experiences)
932
+ if (!candidates.length) return '没有待确认的候选经验。'
933
+ for (const item of candidates) approveExperience(item, 'review-all')
934
+ return [`已批准 ${candidates.length} 条候选经验:`, ...candidates.map((item) => ` ${item.id} → verified(${item.title})`)].join('\n')
935
+ }
936
+ const match = line.match(/^(approve|reject)\s+(.+)$/)
937
+ if (!match) return '用法:/memory review | /memory review --approve-all | /memory review approve <n|id> | /memory review reject <n|id>'
938
+ const target = resolveReviewTarget(match[2])
939
+ if (!target) return `没有匹配的候选:${match[2]}`
940
+ if (match[1] === 'approve') {
941
+ approveExperience(target, 'review-one')
942
+ return `${target.id} 已升级为 verified,下次相似任务会进入召回池。`
943
+ }
944
+ target.status = 'rejected'
945
+ target.verify = { by: 'human', at: new Date().toISOString(), method: 'review-reject' }
946
+ persist()
947
+ return `${target.id} 已标记 rejected,不再召回。`
948
+ }
949
+
950
+ ctx.commands.register({
951
+ name: 'memory',
952
+ description: '跨会话经验:list / show / review / approve / reject / teach / why / debug / stats / config / decay / restore',
953
+ input: { hint: 'list | review [--approve-all|approve <n>|reject <n>] | show <id> | why | debug | stats | config [set <key> <value>] | decay [--apply] | restore <id> | supersede <id> [原因]' },
954
+ handler: ({ rawInput }) => {
955
+ const line = String(rawInput || '').trim()
956
+ const [verb, ...rest] = line.split(/\s+/)
957
+ const arg = rest.join(' ')
958
+ switch (verb) {
959
+ case '':
960
+ case 'list': {
961
+ if (!store.experiences.length) return ok('档案为空。用 /memory teach 或让 agent 调用 experience_record 记录第一条。')
962
+ return ok(
963
+ store.experiences
964
+ .map((item) => {
965
+ const origin = item.source && item.source.auto ? 'auto' : 'manual'
966
+ const count = (item.reverse_examples || item.pitfalls || []).length
967
+ const faded = item.decay && item.decay.state === 'dormant' ? ' · dormant' : ''
968
+ return `${item.id} [${item.status}] (${origin}) ${item.title}${count ? ` · 反例${count}` : ''}${faded}`
969
+ })
970
+ .join('\n'),
971
+ )
972
+ }
973
+ case 'review':
974
+ return ok(reviewCommand(arg))
975
+ case 'show': {
976
+ const found = store.experiences.find((item) => item.id === arg)
977
+ return found ? ok(formatExperience(found)) : err(`没有 ${arg}`)
978
+ }
979
+ case 'reevaluate': {
980
+ const found = arg ? store.experiences.find((item) => item.id === arg) : null
981
+ const targets = found ? [found] : store.experiences.filter((item) => ['candidate', 'auto-verified'].includes(item.status))
982
+ if (!targets.length) return err('没有可重新评估的候选经验')
983
+ const lines = targets.map((item) => {
984
+ const state = E.promotionState(item, CONFIG)
985
+ const result = E.promote(item, CONFIG)
986
+ const label = state.eligible ? 'ready' : state.code
987
+ return `${item.id} [${item.status}] ${label} · ${state.reason}${result.promoted ? ' → 已升级 auto-verified' : ''}`
988
+ })
989
+ persist()
990
+ return ok(lines.join('\n'))
991
+ }
992
+ case 'approve': {
993
+ const found = store.experiences.find((item) => item.id === arg)
994
+ if (!found) return err(`没有 ${arg}`)
995
+ approveExperience(found, 'command')
996
+ return ok(`${found.id} 已升级为 verified,下次相似任务会进入召回池。`)
997
+ }
998
+ case 'reject': {
999
+ const found = store.experiences.find((item) => item.id === arg)
1000
+ if (!found) return err(`没有 ${arg}`)
1001
+ found.status = 'rejected'
1002
+ found.verify = { by: 'human', at: new Date().toISOString(), method: 'command-reject' }
1003
+ persist()
1004
+ return ok(`${found.id} 已标记 rejected,不再召回。`)
1005
+ }
1006
+ case 'supersede': {
1007
+ const parts = String(arg || '').trim().split(/\s+/)
1008
+ const found = store.experiences.find((item) => item.id === parts[0])
1009
+ if (!found) return err(`没有 ${parts[0] || '(空)'}`)
1010
+ const rule = parts.slice(1).join(' ').trim() || '与 AGENTS.md 规则冲突'
1011
+ found.status = 'superseded'
1012
+ found.supersede = { by: 'human', at: new Date().toISOString(), rule }
1013
+ found.verify = null
1014
+ persist()
1015
+ return ok(`${found.id} 已标记 superseded(${rule}):不再召回,同指纹的轨迹也不会再被收录。`)
1016
+ }
1017
+ case 'teach': {
1018
+ const [titlePart, stepsPart] = arg.split('||')
1019
+ const title = String(titlePart || '').trim()
1020
+ const steps = String(stepsPart || '').split(';').map((step) => step.trim()).filter(Boolean)
1021
+ if (!title || !steps.length) return err('用法:/memory teach 标题 || 步骤1; 步骤2')
1022
+ const id = `exp-${store.nextId++}`
1023
+ store.experiences.push({
1024
+ id,
1025
+ status: 'verified',
1026
+ title,
1027
+ taskType: '',
1028
+ when: '',
1029
+ keywords: [...tokens(title)].slice(0, 20),
1030
+ steps,
1031
+ pitfalls: [],
1032
+ evidence: 'human-taught',
1033
+ source: { agentId: 'human', at: new Date().toISOString(), origin: 'human' },
1034
+ verify: { by: 'human', at: new Date().toISOString(), method: 'teach' },
1035
+ confidence: 0.9,
1036
+ uses: 0,
1037
+ lastUsedAt: null,
1038
+ occurrences: 1,
1039
+ })
1040
+ persist()
1041
+ return ok(`${id} 已作为 verified 手动写入:${title}`)
1042
+ }
1043
+ case 'decay': {
1044
+ const parts = String(arg || '').split(/\s+/)
1045
+ const ageIndex = parts.indexOf('--age')
1046
+ const simulatedDays = ageIndex >= 0 ? Number(parts[ageIndex + 1]) : 0
1047
+ const simulated = Number.isFinite(simulatedDays) && simulatedDays > 0
1048
+ const now = simulated ? Date.now() + simulatedDays * 86400000 : Date.now()
1049
+ if (!parts.includes('--apply')) {
1050
+ D.markDecay(store.experiences, Date.now(), CONFIG)
1051
+ persist()
1052
+ const report = D.renderDecayReport(store, now, CONFIG)
1053
+ return ok(simulated ? `${report}\n(模拟:把整库老化 ${simulatedDays} 天,仅影响本次展示)` : report)
1054
+ }
1055
+ const result = D.applyEvictions(store, now, CONFIG, 'command')
1056
+ D.markDecay(store.experiences, now, CONFIG)
1057
+ persist()
1058
+ return ok(`${result.reason}\n归档: ${result.archived.join(', ') || '(无)'}\n恢复: /memory restore <id>`)
1059
+ }
1060
+ case 'restore': {
1061
+ const restored = D.restoreRecord(store, String(arg || '').trim())
1062
+ if (!restored) return err(`archive 里没有 ${arg}`)
1063
+ D.markDecay(store.experiences, Date.now(), CONFIG)
1064
+ persist()
1065
+ return ok(`${restored.id} 已恢复为 ${restored.status},decay 状态已重置。`)
1066
+ }
1067
+ case 'why': {
1068
+ const last = store.turns[store.turns.length - 1]
1069
+ if (!last) return err('还没有记录到 turn 轨迹。')
1070
+ return ok(explainTurn(last))
1071
+ }
1072
+ case 'debug': {
1073
+ const last = store.turns[store.turns.length - 1]
1074
+ const lastInjection = store.injections[store.injections.length - 1]
1075
+ const captures = store.turns.map((turn) => turn.capture).filter((item) => item && item.ok)
1076
+ const avgMs = captures.length
1077
+ ? Math.round(captures.reduce((sum, item) => sum + (item.ms || 0), 0) / captures.length)
1078
+ : 0
1079
+ const lines = [
1080
+ `store: ${STORE_FILE}`,
1081
+ `build: ${BUILD} config: ${JSON.stringify(CONFIG)}`,
1082
+ `experiences: ${store.experiences.length} (verified ${store.experiences.filter((i) => i.status === 'verified').length}, auto-verified ${store.experiences.filter((i) => i.status === 'auto-verified').length}, candidate ${store.experiences.filter((i) => i.status === 'candidate').length}, auto-captured ${store.experiences.filter((i) => i.source && i.source.auto).length})`,
1083
+ `injections: ${store.injections.length} turns: ${store.turns.length} captures: ${captures.length} avgCaptureMs: ${avgMs}`,
1084
+ `lastInjection: ${lastInjection ? `${lastInjection.id} nonce=${lastInjection.nonce} rendered=${lastInjection.rendered} tokens=${lastInjection.tokens} ids=${lastInjection.experienceIds.join(',')}` : '-'}`,
1085
+ '',
1086
+ D.renderDecayReport(store, Date.now(), CONFIG),
1087
+ '',
1088
+ last ? explainTurn(last) : '(无 turn 记录)',
1089
+ ]
1090
+ return ok(lines.join('\n'))
1091
+ }
1092
+ case 'stats': {
1093
+ const candidates = C.reviewTargets(store.experiences)
1094
+ const captures = store.turns.map((turn) => turn.capture).filter((item) => item && item.ok)
1095
+ const avgMs = captures.length
1096
+ ? Math.round(captures.reduce((sum, item) => sum + (item.ms || 0), 0) / captures.length)
1097
+ : 0
1098
+ return ok(
1099
+ [
1100
+ `库 ${store.experiences.length} 条(verified ${store.experiences.filter((i) => i.status === 'verified').length} / candidate ${candidates.length} / rejected ${store.experiences.filter((i) => i.status === 'rejected').length})`,
1101
+ `自动捕获 ${captures.length} 次,平均 ${avgMs}ms(预算 ${CONFIG.captureBudgetMs}ms)`,
1102
+ `注入 ${store.injections.length} 次,最后一条 ${store.injections.length ? store.injections[store.injections.length - 1].id : '-'}`,
1103
+ `配额:今日上限 ${CONFIG.dailyLimit},库上限 ${CONFIG.libraryLimit},去重阈值 ${CONFIG.mergeThreshold}`,
1104
+ ].join('\n'),
1105
+ )
1106
+ }
1107
+ case 'config': {
1108
+ if (!arg) return ok(JSON.stringify(CONFIG, null, 2))
1109
+ const match = arg.match(/^set\s+(\w+)\s+(.+)$/)
1110
+ if (!match) return err('用法:/memory config | /memory config set <key> <value>')
1111
+ const key = match[1]
1112
+ if (!(key in C.DEFAULT_CONFIG)) return err(`未知配置项 ${key},可选:${Object.keys(C.DEFAULT_CONFIG).join(', ')}`)
1113
+ const raw = match[2].trim()
1114
+ let value
1115
+ try {
1116
+ value = C.coerceConfigValue(key, raw)
1117
+ } catch (error) {
1118
+ return err(String(error && error.message))
1119
+ }
1120
+ CONFIG[key] = value
1121
+ saveConfig()
1122
+ return ok(`${key} = ${JSON.stringify(value)}(已写入 ${CONFIG_FILE})`)
1123
+ }
1124
+ default:
1125
+ return err(`未知子命令 ${verb}`)
1126
+ }
1127
+ },
1128
+ })
1129
+
1130
+ function evidenceLine(turn) {
1131
+ const ids = (turn.candidates || []).slice(0, 3).map((item) => item.id)
1132
+ const records = ids.map((id) => store.experiences.find((item) => item.id === id)).filter(Boolean)
1133
+ if (!records.length) return '- (没有候选可评估)'
1134
+ return records
1135
+ .map((record) => {
1136
+ const state = E.promotionState(record, CONFIG)
1137
+ const proof = record.proof || {}
1138
+ const effective = state.stability ?? proof.minStability
1139
+ const raw = proof.minStability ?? 'n/a'
1140
+ const shown = state.stability !== null && state.stability !== undefined && state.stability !== proof.minStability ? `${effective}(原始 ${raw})` : `${raw}`
1141
+ return `${record.id} runs=${(proof.runs || []).length} sessions=${proof.distinctSessions || 0} stability=${shown} · ${state.eligible ? 'ready' : state.reason}`
1142
+ })
1143
+ .join(' | ')
1144
+ }
1145
+
1146
+ function explainTurn(turn) {
1147
+ const verified = store.experiences.filter((item) => item.status === 'verified')
1148
+ const injection = store.injections.find((item) => item.id === turn.injectionId)
1149
+ const pass = (flag) => (flag ? 'PASS' : 'FAIL')
1150
+ const capture = turn.capture || {}
1151
+ const lines = [
1152
+ `turn: session=${turn.sessionId} turn=${turn.turn} steps=${turn.steps} durationMs=${turn.durationMs ?? '?'}`,
1153
+ `query: ${turn.query.slice(0, 160)}`,
1154
+ `queryTokens: ${(turn.queryTokens || []).join(' ') || '(空)'}`,
1155
+ `candidates: ${(turn.candidates || []).map((c) => `${c.id}=${c.score}${c.status ? `(${c.status})` : ''}${c.passed ? '*' : ''}`).join(' ') || '(空)'}`,
1156
+ `threshold: ${turn.threshold}`,
1157
+ `calls: ${(turn.calls || []).length}(失败 ${(turn.calls || []).filter((c) => !c.ok).length})`,
1158
+ `capture: ${capture.ok ? `PASS ${capture.id || capture.merged || ''} (${capture.ms}ms${capture.added ? `, +${capture.added} 反例` : ''})` : `SKIP ${capture.reason || '(未执行)'}`}`,
1159
+ '',
1160
+ '[1 存储] ' + pass(store.experiences.length > 0),
1161
+ '[2 可召回] ' + pass(verified.length > 0) + ` (verified=${verified.length})`,
1162
+ '[2.5 证据] ' + evidenceLine(turn),
1163
+ '[2.6 衰减] ' + ((turn.candidates || []).map((c) => {
1164
+ const record = store.experiences.find((item) => item.id === c.id)
1165
+ return record ? D.decayLine(record, Date.now(), CONFIG) : `${c.id} (已不在库)`
1166
+ }).join(' | ') || '(空)'),
1167
+ `[3 检索] ${injection ? 'PASS' : 'FAIL'} (top=${(turn.candidates && turn.candidates[0] && turn.candidates[0].score) ?? 0}, 阈值 ${turn.threshold})`,
1168
+ `[4 注入] ${injection ? (injection.rendered ? 'PASS' : 'FAIL: 已选择但 prompt 未重新读取(refreshContext 未触发)') : 'FAIL: 没有选择任何经验'}`,
1169
+ `[5 模型可见] 人工核对:回答中是否回显 nonce ${injection ? injection.nonce : '(无)'}`,
1170
+ '[6 生效] 对比 steps/durationMs 与第一次。',
1171
+ '',
1172
+ hintFor(turn, injection, verified),
1173
+ ]
1174
+ return lines.join('\n')
1175
+ }
1176
+
1177
+ function hintFor(turn, injection, verified) {
1178
+ if (!store.experiences.length) return '卡在 [1 存储]:先 experience_record 或 /memory teach。'
1179
+ if (!verified.length) {
1180
+ const blocked = (turn.candidates || []).filter((c) => c.status === 'candidate' && c.score >= turn.threshold)
1181
+ if (blocked.length) {
1182
+ return `卡在 [2 可召回]:${blocked.map((c) => c.id).join(', ')} 得分已过阈值(${blocked.map((c) => c.score).join(', ')}),但 status=candidate —— 运行 /memory review 批量确认后重跑同类任务。`
1183
+ }
1184
+ return '卡在 [2 可召回]:只有 candidate,运行 /memory review 查看并批准。'
1185
+ }
1186
+ if (!injection) {
1187
+ const top = turn.candidates && turn.candidates[0]
1188
+ if (!top) return '卡在 [3 检索]:verified 经验都被过滤(检查 status)。'
1189
+ return `卡在 [3 检索]:最高分 ${top.score} < ${turn.threshold}。补充 title/when/keywords 或调低阈值。`
1190
+ }
1191
+ if (!injection.rendered) return '卡在 [4 注入]:pre-step 选了经验,但 systemPrompt.context 的 text() 未被调用。'
1192
+ return '注入链路已通;剩下只看向下一个问题:模型是否真的按经验执行、效率是否提升。'
1193
+ }
1194
+
1195
+ const ok = (text) => ({ kind: 'success', text })
1196
+ const err = (text) => ({ kind: 'error', text })
1197
+ }
1198
+
1199
+ function safeList(ctx) {
1200
+ try {
1201
+ if (!ctx.agents || typeof ctx.agents.list !== 'function') return []
1202
+ return ctx.agents.list() || []
1203
+ } catch {
1204
+ return []
1205
+ }
1206
+ }
1207
+ // reload-marker: 2026-11-09 round2-l1