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/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
|