dsh-experience 0.0.0-stage → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/capture.js ADDED
@@ -0,0 +1,663 @@
1
+ /**
2
+ * dsh-experience / lib/capture.js — deterministic L1 experience capture.
3
+ *
4
+ * Pure functions only: no ctx, no fs, no network, no model calls. The caller
5
+ * (index.js) owns persistence and identity assignment, so everything here is
6
+ * directly testable with synthetic traces (tools/selftest.mjs).
7
+ */
8
+
9
+ const SECRET_PATTERNS = [
10
+ [/sk-[A-Za-z0-9_-]{16,}/g, '‹redacted›'],
11
+ [/ghp_[A-Za-z0-9]{20,}/g, '‹redacted›'],
12
+ [/AKIA[0-9A-Z]{16}/g, '‹redacted›'],
13
+ [/xox[baprs]-[A-Za-z0-9-]{10,}/g, '‹redacted›'],
14
+ [/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer ‹redacted›'],
15
+ [/([A-Za-z0-9_\-]*(?:api[_-]?key|token|secret|password|passwd|authorization))(\s*[:=]\s*)\S+/gi, '$1$2‹redacted›'],
16
+ [/(?=[A-Za-z0-9+/]*[A-Z])(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[0-9])[A-Za-z0-9+/]{40,}={0,2}/g, '‹redacted›'],
17
+ ]
18
+
19
+ const PATH_PATTERNS = [
20
+ [/[A-Za-z]:\\Users\\[^\\\s]+/gi, '~'],
21
+ [/[A-Za-z]:\\(?:[^\\\s]+\\){0,4}/g, '<path>/'],
22
+ [/\\\\[^\\\s]+\\/g, '<unc>/'],
23
+ ]
24
+
25
+ const KNOWN_CONFIG_FILES = new Set([
26
+ 'package.json', 'pyproject.toml', 'tsconfig.json', 'Cargo.toml', 'go.mod', 'requirements.txt',
27
+ ])
28
+
29
+ export const DEFAULT_CONFIG = {
30
+ dailyLimit: 10,
31
+ libraryLimit: 200,
32
+ mergeThreshold: 0.75,
33
+ scopeMustMatch: true,
34
+ captureBudgetMs: 20,
35
+ maxRecordChars: 2000,
36
+ maxCalls: 40,
37
+ autoPromote: false,
38
+ minDistinctSessions: 2,
39
+ stepsJaccard: 0.7,
40
+ allowAutoVerified: false,
41
+ minEffectiveSteps: 2,
42
+ }
43
+
44
+ export function redact(text) {
45
+ let out = String(text == null ? '' : text)
46
+ for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement)
47
+ for (const [pattern, replacement] of PATH_PATTERNS) out = out.replace(pattern, replacement)
48
+ return out
49
+ }
50
+
51
+ export function clip(text, limit = 160) {
52
+ const source = redact(text).replace(/\s+/g, ' ').trim()
53
+ if (source.length <= limit) return source
54
+ return source.slice(0, Math.max(0, limit - 1)) + '…'
55
+ }
56
+
57
+ /** Truncate without redacting: features (scope, fingerprint) need the real path. */
58
+ export function clipRaw(text, limit = 160) {
59
+ const source = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
60
+ if (source.length <= limit) return source
61
+ return source.slice(0, Math.max(0, limit - 1)) + '…'
62
+ }
63
+
64
+ const WRITE_TOOL = /^(write|edit|apply|patch|create|replace)/i
65
+ const READ_TOOL = /^(read|view|open|cat)/i
66
+ const SEARCH_TOOL = /^(grep|glob|search|find|query)/i
67
+ const RUN_TOOL = /^(pwsh|bash|shell|exec|run|command|terminal)/i
68
+
69
+ function firstString(value) {
70
+ if (typeof value === 'string') return value
71
+ if (Array.isArray(value)) return value.map(firstString).find(Boolean) || ''
72
+ if (value && typeof value === 'object') {
73
+ for (const key of ['path', 'file', 'file_path', 'command', 'pattern', 'query', 'cmd']) {
74
+ const found = firstString(value[key])
75
+ if (found) return found
76
+ }
77
+ }
78
+ return ''
79
+ }
80
+
81
+ export function normalizePath(input) {
82
+ const raw = String(input || '').trim().replace(/^["'`]|["'`]$/g, '')
83
+ if (!raw) return ''
84
+ const segments = raw.split(/[\\/]+/).filter(Boolean)
85
+ const base = segments[segments.length - 1] || ''
86
+ if (KNOWN_CONFIG_FILES.has(base)) return base
87
+ const extMatch = base.match(/\.[A-Za-z0-9]+$/)
88
+ const ext = extMatch ? extMatch[0] : ''
89
+ const stem = ext ? base.slice(0, -ext.length) : base
90
+ const keepStem = /^(package|pyproject|index|main|mod)$/i.test(stem)
91
+ const name = keepStem ? stem : '<name>'
92
+ const parents = segments.slice(Math.max(0, segments.length - 2), segments.length - 1)
93
+ return [...parents, name + ext].join('/')
94
+ }
95
+
96
+ export function normalizeCommand(command) {
97
+ const raw = clip(command, 120)
98
+ if (!raw) return ''
99
+ if (/node\s+--test\b/.test(raw)) return 'node --test'
100
+ const nodeCall = raw.match(/node\s+([^\s|&;]+)/)
101
+ if (nodeCall) return `node ${normalizePath(nodeCall[1])}`
102
+ const parts = raw.split(/\s+/)
103
+ return parts.slice(0, 2).join(' ')
104
+ }
105
+
106
+ export function classify(call) {
107
+ const name = String((call && call.name) || '')
108
+ const hint = firstString(call && (call.hint || call.argsHint || call.args))
109
+ let kind = 'other'
110
+ if (WRITE_TOOL.test(name)) kind = 'write'
111
+ else if (READ_TOOL.test(name)) kind = 'read'
112
+ else if (SEARCH_TOOL.test(name)) kind = 'search'
113
+ else if (RUN_TOOL.test(name)) kind = 'run'
114
+ let target = ''
115
+ if (kind === 'run') target = normalizeCommand(hint)
116
+ else if (kind === 'write' || kind === 'read') target = normalizePath(hint)
117
+ else if (kind === 'search') target = clip(hint, 60)
118
+ else target = clip(hint, 40)
119
+ return { kind, target, name }
120
+ }
121
+
122
+ export function stepLabel(call) {
123
+ const { kind, target, name } = classify(call)
124
+ switch (kind) {
125
+ case 'write':
126
+ return target ? `写 ${target}` : '写文件'
127
+ case 'read':
128
+ return target ? `读 ${target.split('/').pop()}` : '读文件'
129
+ case 'run':
130
+ return target ? `跑 ${target}` : '执行命令'
131
+ case 'search':
132
+ return target ? `检索 ${target}` : '检索'
133
+ default:
134
+ return target ? `${name}(${target})` : name || '调用工具'
135
+ }
136
+ }
137
+
138
+ const EXPLORE_COMMAND = /\b(Get-ChildItem|Get-Content|Get-Item|Test-Path|Select-String|Select-Object|Measure-Object|gci|gc|dir|ls|cat|type|head|tail|find|rg|grep|where|which|stat)\b/i
139
+ const TEST_COMMAND = /\b(test|spec|jest|vitest|mocha|pytest|check|--test)\b/i
140
+
141
+ /** A successful run whose command looks like a test/verification command. */
142
+ export function isTestCall(call) {
143
+ if (!call) return false
144
+ if (classify(call).kind !== 'run') return false
145
+ const raw = firstString(call.hint || call.argsHint || call.args)
146
+ return TEST_COMMAND.test(raw)
147
+ }
148
+
149
+ /**
150
+ * How much a successful call contributes to the *recipe* (not the transcript):
151
+ * 4 = execution/test run, 3 = write/edit, 2 = other real commands, 0 = exploration.
152
+ * Exploration (listing a directory, reading a reference file, grepping) is what an
153
+ * agent does because it does not know the answer yet; it must not become the
154
+ * "core steps" of the experience.
155
+ */
156
+ export function stepPriority(call) {
157
+ const { kind } = classify(call)
158
+ if (kind === 'write') return 3
159
+ if (kind === 'run') {
160
+ const command = firstString(call && (call.hint || call.argsHint || call.args))
161
+ if (TEST_COMMAND.test(command)) return 4
162
+ if (EXPLORE_COMMAND.test(command)) return 0
163
+ return 2
164
+ }
165
+ return 0
166
+ }
167
+
168
+ /**
169
+ * A step label is only useful if a human could read it as an instruction.
170
+ * PowerShell scaffolding (variable assembly, New-Item/Test-Path probes, host
171
+ * output) is transcript noise: it describes how the agent measured the world,
172
+ * not what a second run should do.
173
+ */
174
+ const NOISE_PREFIX = /^(\$|New-Item|New-ItemProperty|New-Object|Test-Path|Get-ChildItem|Get-Item|Set-Location|Push-Location|Pop-Location|Write-Host|Write-Output|Copy-Item|Remove-Item|Move-Item|Rename-Item)\b/
175
+ const ASSIGNMENT = /^[A-Za-z_$][\w$]*\s*=[^=]/
176
+
177
+ export function isNoiseStep(label) {
178
+ const text = String(label == null ? '' : label).trim()
179
+ if (!text) return true
180
+ if (NOISE_PREFIX.test(text) || ASSIGNMENT.test(text)) return true
181
+ const body = text.replace(/^(写|读|跑|检索)\s+/, '')
182
+ if (body !== text && (NOISE_PREFIX.test(body) || ASSIGNMENT.test(body))) return true
183
+ // a bare token with no action word and no separator is a fragment
184
+ if (!/\s/.test(text)) return true
185
+ return false
186
+ }
187
+
188
+ /** Steps that carry the recipe: successful writes and test/verification runs. */
189
+ export function effectiveSteps(calls) {
190
+ let count = 0
191
+ for (const call of calls || []) {
192
+ if (!call || !call.ok) continue
193
+ if (stepPriority(call) < 3) continue
194
+ if (isNoiseStep(stepLabel(call))) continue
195
+ count++
196
+ }
197
+ return count
198
+ }
199
+
200
+ const ACTION_VERBS = /^(新增|写|跑通|修改|修复|重构|实现|添加|删除|更新|重命名|移动|替换|补|加|改为)/
201
+
202
+ /** The object of a title must name a concrete artifact, not only <placeholders>. */
203
+ export function actionableTitle(title) {
204
+ const text = String(title == null ? '' : title).trim()
205
+ if (!ACTION_VERBS.test(text)) return false
206
+ const object = text.replace(ACTION_VERBS, '').trim()
207
+ if (!object) return false
208
+ if (object.startsWith('$') || object.startsWith('-')) return false
209
+ // Drop <placeholders> first: what remains must still name something. A title
210
+ // like "写 src/<name>.js" collapses to "src/.js" — a directory and an
211
+ // extension with no artifact name — and is therefore rejected.
212
+ const stripped = object.replace(/<[^>]*>/g, '')
213
+ const base = stripped.split(/[\\/]+/).filter(Boolean).pop() || ''
214
+ const stem = base.replace(/\.[^.]+$/, '').replace(/[\s._-]+/g, '')
215
+ return /[A-Za-z0-9]{2,}/.test(stem)
216
+ }
217
+
218
+ /**
219
+ * G7 — refuses captures whose steps or title are not a reusable recipe.
220
+ * Runs after the G1..G6 chain; the caller writes trace.skipReason = reason.
221
+ */
222
+ export function gateStepQuality(trace, record, config) {
223
+ const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }
224
+ const calls = (trace && trace.calls) || []
225
+ const effective = effectiveSteps(calls)
226
+ if (effective < cfg.minEffectiveSteps) {
227
+ return {
228
+ ok: false,
229
+ reason: `G7: 有效步骤 ${effective} < ${cfg.minEffectiveSteps}(只有写操作与测试执行算核心步骤)`,
230
+ effective,
231
+ }
232
+ }
233
+ const title = String((record && record.title) || '')
234
+ if (!actionableTitle(title)) {
235
+ return { ok: false, reason: `G7: 标题「${title}」不含动作+对象,按命令碎片丢弃`, effective }
236
+ }
237
+ const when = String((record && record.when) || '')
238
+ if (/完成一项可复用的改动/.test(when)) {
239
+ return { ok: false, reason: 'G7: when 是兜底文案,说明任务类型未被识别', effective }
240
+ }
241
+ return { ok: true, effective }
242
+ }
243
+
244
+ export function summarizeCalls(calls) {
245
+ const steps = []
246
+ for (const call of calls || []) {
247
+ if (!call || !call.ok) continue
248
+ if (stepPriority(call) === 0) continue
249
+ const label = stepLabel(call)
250
+ if (!label) continue
251
+ if (isNoiseStep(label)) continue
252
+ if (steps[steps.length - 1] === label) continue
253
+ steps.push(label)
254
+ if (steps.length >= 8) break
255
+ }
256
+ return steps
257
+ }
258
+
259
+ /**
260
+ * What the turn actually produced, independent of how many steps it needed.
261
+ * A second run that only re-reads and re-runs the tests has the same fingerprint
262
+ * as the first run that scaffolded everything — that is the real "same task".
263
+ */
264
+ export function outcomeFingerprint(calls) {
265
+ const list = calls || []
266
+ const artifactOf = (call) => {
267
+ const raw = firstString(call && (call.hint || call.argsHint || call.args)) || ''
268
+ const parts = raw.split(/[\\/]+/).filter(Boolean)
269
+ return (parts.length >= 2 ? parts.slice(-2).join('/') : parts[0] || '').toLowerCase()
270
+ }
271
+ const isCode = (target) => /\.(js|mjs|cjs|ts|tsx|jsx|py|go|rs|java|cs|rb|php)$/.test(target)
272
+ const writes = list.filter((call) => call && call.ok && classify(call).kind === 'write').map(artifactOf)
273
+ const reads = list.filter((call) => call && call.ok && classify(call).kind === 'read').map(artifactOf)
274
+ const testCalls = list.filter((call) => call && isTestCall(call))
275
+ const tests = testCalls.map((call) => classify(call).target)
276
+ // A run that failed and then went green still ends green; the failure itself is
277
+ // captured as a reverse example, not as a reason to refuse the merge.
278
+ const green = testCalls.length > 0 && testCalls[testCalls.length - 1].ok === true
279
+ const artifacts = (writes.length ? writes : reads).filter(isCode)
280
+ return {
281
+ targets: [...new Set(artifacts)].sort(),
282
+ tests: [...new Set(tests)].sort(),
283
+ green,
284
+ verificationOnly: writes.length === 0,
285
+ }
286
+ }
287
+
288
+ /** Same project + same produced artifact + green tests => the same task, whatever the steps. */
289
+ export function fingerprintsMatch(left, right) {
290
+ const a = left && left.fingerprint
291
+ const b = right && right.fingerprint
292
+ if (!a || !b || !a.green || !b.green) return false
293
+ const have = new Set(a.targets || [])
294
+ const want = new Set(b.targets || [])
295
+ if (!have.size || !want.size) return false
296
+ for (const target of have) if (want.has(target)) return true
297
+ return false
298
+ }
299
+
300
+ function errorCode(call) {
301
+ const text = String((call && (call.errorCode || call.error)) || '')
302
+ const known = text.match(/\b(EPERM|ENOENT|EACCES|ETIMEDOUT|EEXIST|EISDIR|TypeError|RangeError|SyntaxError|AssertionError)\b/)
303
+ if (known) return known[1]
304
+ const exit = text.match(/exit code (\d+)/i)
305
+ if (exit) return `exit ${exit[1]}`
306
+ return '错误'
307
+ }
308
+
309
+ /** The first line of the failure is what a second run actually needs to avoid. */
310
+ function errorDetail(call) {
311
+ const text = String((call && (call.errorCode || call.error)) || '').replace(/\s+/g, ' ').trim()
312
+ if (!text) return ''
313
+ const head = text.split(/(?<=\b(?:Error|EPERM|ENOENT|EACCES|EEXIST|EISDIR)\b)/)[0]
314
+ return clip(head || text, 80)
315
+ }
316
+
317
+ export function findRecoveries(calls) {
318
+ const list = calls || []
319
+ const recoveries = []
320
+ const seen = new Set()
321
+ for (let i = 0; i < list.length; i++) {
322
+ const failed = list[i]
323
+ if (!failed || failed.ok) continue
324
+ const failedLabel = stepLabel(failed)
325
+ for (let j = i + 1; j < list.length; j++) {
326
+ const ok = list[j]
327
+ if (!ok || !ok.ok) continue
328
+ const failedKind = classify(failed).kind
329
+ const okKind = classify(ok).kind
330
+ const sameKind = failedKind === okKind
331
+ // Compare the *raw* basename: normalization collapses `a.js` and `b.js`
332
+ // to the same `<name>.js`, which would fake a recovery between two
333
+ // unrelated files.
334
+ const rawBase = (call) => {
335
+ const raw = firstString(call && (call.hint || call.argsHint || call.args))
336
+ const segment = String(raw).split(/[\\/]+/).filter(Boolean).pop() || ''
337
+ return segment.replace(/\.[^.]+$/, '').toLowerCase()
338
+ }
339
+ const failedBase = rawBase(failed)
340
+ const okBase = rawBase(ok)
341
+ // A recovery must be *related* to the failure, otherwise any later success
342
+ // of the same tool kind would masquerade as one:
343
+ // - same kind + same raw basename (normal retry / file moved),
344
+ // - same command head for two runs (node --test -> node test/x.mjs).
345
+ const sameHead =
346
+ failedKind === 'run' &&
347
+ okKind === 'run' &&
348
+ classify(failed).target.split(/\s+/)[0] === classify(ok).target.split(/\s+/)[0]
349
+ const related = (sameKind && Boolean(failedBase) && failedBase === okBase) || sameHead
350
+ if (!related) continue
351
+ const line = `${failedLabel} 失败(${errorDetail(failed) || errorCode(failed)}),改用 ${stepLabel(ok)} 成功`
352
+ if (!seen.has(line)) {
353
+ seen.add(line)
354
+ recoveries.push(line)
355
+ }
356
+ break
357
+ }
358
+ if (recoveries.length >= 3) break
359
+ }
360
+ return recoveries
361
+ }
362
+
363
+ export function inferTaskType(calls, cwd) {
364
+ const haystack = (calls || [])
365
+ .map((call) => `${firstString(call && (call.hint || call.argsHint || call.args))} ${call && call.name ? call.name : ''}`)
366
+ .join(' ')
367
+ .toLowerCase()
368
+ const dir = String(cwd || '').toLowerCase()
369
+ if (/\.(tsx|jsx|vue|svelte)\b/.test(haystack)) return 'web/frontend'
370
+ if (/\.(ts)\b/.test(haystack)) return 'web/frontend'
371
+ if (/\.py\b|pyproject\.toml|requirements\.txt/.test(haystack)) return 'python/module'
372
+ if (/\.go\b|go\.mod/.test(haystack)) return 'go/module'
373
+ if (/\.rs\b|cargo\.toml/.test(haystack)) return 'rust/module'
374
+ if (/package\.json|\.m?js\b|node/.test(haystack) || /node/.test(dir)) return 'node-project/feature'
375
+ return 'workspace/general'
376
+ }
377
+
378
+ export function shouldCapture(trace, counts, config) {
379
+ const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }
380
+ const calls = (trace && trace.calls) || []
381
+ const successes = calls.filter((call) => call && call.ok)
382
+ if (successes.length < 2) return { ok: false, reason: 'G1: 成功工具调用 < 2' }
383
+
384
+ const names = calls.map((call) => String((call && call.name) || ''))
385
+ const query = String((trace && trace.query) || '').trim()
386
+ if (names.some((name) => name.startsWith('experience_')) || query.startsWith('/memory')) {
387
+ return { ok: false, reason: 'G2: 本 turn 是经验管理操作,不回灌' }
388
+ }
389
+
390
+ const writes = successes.filter((call) => classify(call).kind === 'write')
391
+ const recoveries = findRecoveries(calls)
392
+ // A verification-only turn (re-read the code, run the tests, all green) is valid
393
+ // independent evidence: it re-proves the recipe without a write operation.
394
+ const tests = successes.filter((call) => isTestCall(call))
395
+ const testFailures = calls.filter((call) => call && !call.ok && isTestCall(call))
396
+ const verifiedGreen = tests.length > 0 && testFailures.length === 0
397
+ if (!writes.length && !recoveries.length && !verifiedGreen) {
398
+ return { ok: false, reason: 'G3: 无成功写操作、无失败后成功,且不是全绿验证轨迹' }
399
+ }
400
+
401
+ if ((counts && counts.todayAdded) >= cfg.dailyLimit) {
402
+ return { ok: false, reason: `G5: 今日自动捕获已达上限 ${cfg.dailyLimit}` }
403
+ }
404
+ if ((counts && counts.autoTotal) >= cfg.libraryLimit) {
405
+ return { ok: false, reason: `G5: 自动经验库已达上限 ${cfg.libraryLimit}` }
406
+ }
407
+ return { ok: true, writes, recoveries, verificationOnly: !writes.length && !recoveries.length }
408
+ }
409
+
410
+ function verbsOf(taskType) {
411
+ if (taskType === 'node-project/feature') return '新增函数/模块并配用例'
412
+ if (taskType === 'python/module') return '新增 Python 模块并配测试'
413
+ if (taskType === 'web/frontend') return '改前端组件'
414
+ if (taskType === 'go/module') return '新增 Go 包'
415
+ if (taskType === 'rust/module') return '新增 Rust 模块'
416
+ return '完成一项可复用的改动'
417
+ }
418
+
419
+ export function workspaceName(cwd) {
420
+ const segments = String(cwd || '').split(/[\\/]+/).filter(Boolean)
421
+ return segments[segments.length - 1] || ''
422
+ }
423
+
424
+ const GENERIC_DIRS = new Set([
425
+ 'users', 'home', 'desktop', 'documents', 'downloads', 'src', 'test', 'tests',
426
+ 'lib', 'node_modules', 'bin', 'tmp', 'temp', 'app', 'apps', 'workspace', 'workspaces',
427
+ ])
428
+
429
+ /**
430
+ * Project directories mentioned in the raw task text.
431
+ * "D:\\w\\acme-app\\src\\pad.js" -> ["acme-app"]; only the deepest non-generic segment is
432
+ * kept, so a user name or a workspace root can never make two projects look alike.
433
+ */
434
+ export function mentionedDirs(text, limit = 6) {
435
+ const found = new Set()
436
+ for (const match of String(text || '').matchAll(/(?:[A-Za-z]:)?(?:[\\/][A-Za-z0-9_.-]+)+/g)) {
437
+ // "node-project/feature" is a task type, not a directory.
438
+ if (/^[a-z][a-z-]*\/[a-z][a-z-]*$/.test(match[0])) continue
439
+ const parts = match[0].split(/[\\/]+/).filter(Boolean).filter((part) => !/^[A-Za-z]:$/.test(part))
440
+ // A lone "/feature" is the tail of "task-type/feature", not a path.
441
+ if (!/^[A-Za-z]:/.test(match[0]) && parts.length < 2) continue
442
+ if (/^[A-Za-z0-9_.-]+\.[A-Za-z0-9]+$/.test(parts[parts.length - 1] || '')) parts.pop()
443
+ const dirs = parts.map((part) => part.toLowerCase()).filter((part) => part.length >= 2 && !GENERIC_DIRS.has(part))
444
+ if (!dirs.length) continue
445
+ found.add(dirs[dirs.length - 1])
446
+ }
447
+ return [...found].slice(0, limit)
448
+ }
449
+
450
+ /** Idempotent: redacts a record written by an older build that had no redaction. */
451
+ export function scrubRecord(record) {
452
+ if (!record || typeof record !== 'object') return false
453
+ let changed = false
454
+ const fix = (value) => {
455
+ if (typeof value !== 'string') return value
456
+ const next = redact(value)
457
+ if (next !== value) changed = true
458
+ return next
459
+ }
460
+ for (const key of ['title', 'when', 'evidence']) {
461
+ if (typeof record[key] === 'string') record[key] = fix(record[key])
462
+ }
463
+ for (const key of ['steps', 'reverse_examples', 'pitfalls', 'keywords']) {
464
+ if (Array.isArray(record[key])) record[key] = record[key].map(fix)
465
+ }
466
+ return changed
467
+ }
468
+
469
+ /** Project slugs recorded before `scope` existed, e.g. keywords "acme-app", "acme-app/**". */
470
+ export function scopeOf(record) {
471
+ if (!record) return []
472
+ if (Array.isArray(record.scope) && record.scope.length) return record.scope.map((item) => String(item).toLowerCase())
473
+ const out = []
474
+ for (const word of record.keywords || []) {
475
+ const text = String(word)
476
+ if (/^[a-z][a-z-]*\/[a-z][a-z-]*$/.test(text)) continue // taskType, not a directory
477
+ const head = text.split(/[\\/*]/)[0].toLowerCase()
478
+ if (/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(head)) out.push(head)
479
+ }
480
+ return [...new Set(out)]
481
+ }
482
+
483
+ /** Two records describe different targets when both name a project dir and the names disagree. */
484
+ export function scopeConflicts(existing, draft) {
485
+ const left = scopeOf(existing)
486
+ const right = scopeOf(draft)
487
+ if (!left.length || !right.length) return false
488
+ return !left.some((item) => right.includes(item))
489
+ }
490
+
491
+ /** Raw basename of a written artifact, extension stripped ("src/pad.js" -> "pad"). */
492
+ export function artifactName(call) {
493
+ const raw = firstString(call && (call.hint || call.argsHint || call.args)) || ''
494
+ const parts = raw.split(/[\\/]+/).filter(Boolean)
495
+ const base = (parts[parts.length - 1] || '').replace(/\.[^.]+$/, '')
496
+ return base.replace(/[^A-Za-z0-9_.-]/g, '').trim()
497
+ }
498
+
499
+ export function buildCandidate(trace, info, config) {
500
+ const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }
501
+ const calls = ((trace && trace.calls) || []).slice(0, cfg.maxCalls)
502
+ const taskType = inferTaskType(calls, info && info.cwd)
503
+ const steps = summarizeCalls(calls)
504
+ const recoveries = findRecoveries(calls)
505
+ const writes = calls.filter((call) => call && call.ok && classify(call).kind === 'write')
506
+ const runs = calls.filter((call) => call && call.ok && classify(call).kind === 'run')
507
+
508
+ const primary = writes.length ? classify(writes[0]).target : runs.length ? classify(runs[0]).target : ''
509
+ const verb = writes.length ? '写' : '跑通'
510
+ // Prefer a concrete artifact name (raw basename, before path normalization):
511
+ // "<path>/<name>.js" cannot name what was produced, "slugify.js" can.
512
+ const named = writes
513
+ .map((call) => artifactName(call))
514
+ .find((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) && /[A-Za-z0-9]{2,}/.test(name))
515
+ const title = clip(named ? `新增 ${named}` : primary ? `${verb} ${primary}` : verbsOf(taskType), 110)
516
+ const place = workspaceName(info && info.cwd)
517
+ const when = clip(place ? `在 ${place} 里 ${verbsOf(taskType)}` : `当需要${verbsOf(taskType)}时`, 160)
518
+ const keywords = [...new Set([taskType, ...steps.map((step) => step.split(' ')[1] || '').filter(Boolean)])]
519
+ .filter(Boolean)
520
+ .slice(0, 12)
521
+ .map((word) => clip(word, 40))
522
+
523
+ const record = {
524
+ status: 'candidate',
525
+ // Scope detection needs the raw paths; every rendered field below is normalized.
526
+ scope: mentionedDirs(
527
+ [
528
+ ...(calls || []).map((call) => firstString(call && (call.hint || call.argsHint || call.args))),
529
+ trace && trace.query,
530
+ ].join(' '),
531
+ ),
532
+ fingerprint: outcomeFingerprint(calls),
533
+ title,
534
+ taskType,
535
+ when,
536
+ keywords,
537
+ steps,
538
+ reverse_examples: recoveries,
539
+ evidence: `${(info && info.sessionId) || '?'}:turn-${(trace && trace.turn) || '?'} · calls=${calls.length} errors=${calls.filter((call) => call && !call.ok).length}`,
540
+ source: { auto: true, agentId: (info && info.agentId) || 'unknown', at: new Date().toISOString(), origin: 'l1' },
541
+ verify: null,
542
+ confidence: 0.25,
543
+ uses: 0,
544
+ lastUsedAt: null,
545
+ occurrences: 1,
546
+ }
547
+
548
+ if (JSON.stringify(record).length > cfg.maxRecordChars) {
549
+ record.truncated = true
550
+ while (record.steps.length > 1 && JSON.stringify(record).length > cfg.maxRecordChars) record.steps.pop()
551
+ while (record.reverse_examples.length > 1 && JSON.stringify(record).length > cfg.maxRecordChars) {
552
+ record.reverse_examples.pop()
553
+ }
554
+ }
555
+ return record
556
+ }
557
+
558
+ export function mergeInto(existing, draft) {
559
+ existing.occurrences = (existing.occurrences || 1) + 1
560
+ existing.lastSeenAt = new Date().toISOString()
561
+ // Older records had no fingerprint; adopt this run's so the next occurrence matches.
562
+ if (!existing.fingerprint && draft && draft.fingerprint) existing.fingerprint = draft.fingerprint
563
+ const canonical = existing.reverse_examples || existing.pitfalls || []
564
+ const seen = new Set(canonical)
565
+ const added = []
566
+ for (const item of draft.reverse_examples || []) {
567
+ if (!seen.has(item)) {
568
+ seen.add(item)
569
+ added.push(item)
570
+ }
571
+ }
572
+ existing.reverse_examples = [...canonical, ...added].slice(0, 10)
573
+ if (existing.pitfalls) existing.pitfalls = existing.reverse_examples
574
+
575
+ const stepSeen = new Set(existing.steps || [])
576
+ const newSteps = []
577
+ for (const step of draft.steps || []) {
578
+ if (!stepSeen.has(step) && newSteps.length < 4) {
579
+ stepSeen.add(step)
580
+ newSteps.push(step)
581
+ }
582
+ }
583
+ if (newSteps.length) existing.steps = [...(existing.steps || []), ...newSteps].slice(0, 10)
584
+
585
+ if (draft.evidence && existing.evidence && !existing.evidence.includes(draft.evidence)) {
586
+ existing.evidence = clip(`${existing.evidence}; ${draft.evidence}`, 240)
587
+ }
588
+ return { added, occurrences: existing.occurrences, newSteps }
589
+ }
590
+
591
+ export function reviewTargets(experiences) {
592
+ return (experiences || []).filter((item) => item && (item.status === 'candidate' || item.status === 'auto-verified'))
593
+ }
594
+
595
+ /**
596
+ * G8: a recipe that was rejected as rule-conflicting must not be re-adopted.
597
+ * A new turn that reproduces the same fingerprint would otherwise create a fresh
598
+ * candidate and wait for the same human to reject it again.
599
+ */
600
+ export function gateSuperseded(draft, experiences) {
601
+ const hit = (experiences || []).find(
602
+ (item) => item && item.status === 'superseded' && fingerprintsMatch(item, draft),
603
+ )
604
+ if (!hit) return { ok: true }
605
+ const rule = (hit.supersede && (hit.supersede.rule || hit.supersede.reason)) || '规则冲突'
606
+ return { ok: false, hit, reason: `G8: 与已否决经验 ${hit.id} 指纹相同(${rule})→ 不再收录` }
607
+ }
608
+
609
+ export function renderReview(experiences) {
610
+ const candidates = reviewTargets(experiences)
611
+ const rejected = (experiences || []).filter((item) => item && item.status === 'superseded')
612
+ if (!candidates.length) {
613
+ return rejected.length
614
+ ? `没有待确认的候选经验。\n已否决(规则冲突)${rejected.length} 条,不再收录、也不会召回。`
615
+ : '没有待确认的候选经验。'
616
+ }
617
+ const auto = candidates.filter((item) => item.source && item.source.auto).length
618
+ const lines = [`候选 ${candidates.length} 条(自动捕获 ${auto} / 手动 ${candidates.length - auto})|批准后即可跨会话召回`, '']
619
+ candidates.forEach((item, index) => {
620
+ const flags = [item.source && item.source.auto ? 'auto' : 'manual']
621
+ lines.push(`#${index + 1} ${item.id} [${flags.join('/')}] ${item.title}`)
622
+ lines.push(` 触发条件: ${item.when || '-'}`)
623
+ const steps = item.steps || []
624
+ const shown = steps.slice(0, 3).map((step, i) => `${i + 1}) ${step}`).join(' ')
625
+ const more = steps.length > 3 ? ` …+${steps.length - 3}` : ''
626
+ lines.push(` 核心步骤: ${shown || '-'}${more}`)
627
+ const pitfalls = item.reverse_examples || item.pitfalls || []
628
+ lines.push(` 避坑点: ${pitfalls.length ? pitfalls.join(';') : '(无)'}`)
629
+ lines.push(` id=${item.id} occurrences=${item.occurrences || 1}`)
630
+ lines.push('')
631
+ })
632
+ if (rejected.length) {
633
+ lines.push(`已否决(规则冲突)${rejected.length} 条,不再召回,仅用于防止重复收录:`)
634
+ for (const item of rejected.slice(0, 5)) {
635
+ const why = (item.supersede && (item.supersede.rule || item.supersede.reason)) || '规则冲突'
636
+ lines.push(` ${item.id} ${item.title} · ${why}`)
637
+ }
638
+ lines.push('')
639
+ }
640
+ lines.push('批量: /memory review --approve-all')
641
+ lines.push('单条: /memory review approve 1 | /memory review reject 1')
642
+ return lines.join('\n')
643
+ }
644
+ /**
645
+ * Coerce a user-typed config value to the type of its default.
646
+ * "/memory config set autoPromote true" must store boolean true, not the string "true".
647
+ */
648
+ export function coerceConfigValue(key, raw, defaults = DEFAULT_CONFIG) {
649
+ if (!(key in defaults)) throw new Error('未知配置项 ' + key)
650
+ const fallback = defaults[key]
651
+ const text = String(raw).trim()
652
+ if (typeof fallback === 'boolean') {
653
+ if (/^(true|1|yes|on)$/i.test(text)) return true
654
+ if (/^(false|0|no|off)$/i.test(text)) return false
655
+ throw new Error(key + ' 是布尔值,接受 true/false')
656
+ }
657
+ if (typeof fallback === 'number') {
658
+ const num = Number(text)
659
+ if (!Number.isFinite(num)) throw new Error(key + " 是数字,收到 " + text)
660
+ return num
661
+ }
662
+ return text
663
+ }