dsh-experience 0.0.0-stage → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +225 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +156 -2
- package/USER-GUIDE.md +92 -0
- package/config.example.json +13 -0
- package/cordis.patch.yml +4 -0
- package/index.js +1207 -0
- package/lib/capture.js +663 -0
- package/lib/decay.js +253 -0
- package/lib/evidence.js +206 -0
- package/lib/lookup.js +53 -0
- package/package.json +60 -4
- package/store.example.json +10 -0
- package/tools/e2e.mjs +342 -0
- package/tools/selftest.mjs +925 -0
package/lib/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
|
+
}
|