dsh-experience 1.0.5 → 1.0.14
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 +185 -0
- package/README.md +12 -4
- package/index.js +140 -90
- package/lib/capture.js +149 -12
- package/lib/decay.js +9 -4
- package/lib/recall.js +220 -0
- package/lib/store.js +66 -0
- package/package.json +1 -1
- package/tools/e2e.mjs +89 -1
- package/tools/selftest.mjs +272 -0
package/lib/capture.js
CHANGED
|
@@ -39,6 +39,7 @@ export const DEFAULT_CONFIG = {
|
|
|
39
39
|
stepsJaccard: 0.7,
|
|
40
40
|
allowAutoVerified: false,
|
|
41
41
|
minEffectiveSteps: 2,
|
|
42
|
+
recallThreshold: 0.15,
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function redact(text) {
|
|
@@ -197,7 +198,58 @@ export function effectiveSteps(calls) {
|
|
|
197
198
|
return count
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
const ACTION_VERBS = /^(
|
|
201
|
+
const ACTION_VERBS = /^(新增|写|跑通|跑|执行|修改|修复|重构|实现|添加|删除|更新|重命名|移动|替换|补|加|改为)/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Executions worth repeating. A run recipe is "how to invoke this thing", so a
|
|
205
|
+
* bare mkdir / dir listing / file read never counts: those describe discovery,
|
|
206
|
+
* not the recipe.
|
|
207
|
+
*/
|
|
208
|
+
export function runSteps(calls) {
|
|
209
|
+
const kept = []
|
|
210
|
+
for (const call of calls || []) {
|
|
211
|
+
if (!call || !call.ok) continue
|
|
212
|
+
if (classify(call).kind !== 'run') continue
|
|
213
|
+
if (stepPriority(call) < 2) continue
|
|
214
|
+
if (isNoiseStep(stepLabel(call))) continue
|
|
215
|
+
kept.push(call)
|
|
216
|
+
}
|
|
217
|
+
return kept
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The program a run call actually launched: "& \"C:\\...\\node.exe\" x.mjs" -> node. */
|
|
221
|
+
export function commandHeads(calls) {
|
|
222
|
+
const heads = []
|
|
223
|
+
for (const call of calls || []) {
|
|
224
|
+
if (!call || !call.ok) continue
|
|
225
|
+
if (classify(call).kind !== 'run') continue
|
|
226
|
+
if (isNoiseStep(stepLabel(call))) continue
|
|
227
|
+
const raw = firstString(call && (call.hint || call.argsHint || call.args)) || ''
|
|
228
|
+
const lead = raw.trim().replace(/^&\s*/, '')
|
|
229
|
+
const quoted = lead.match(/^("[^"]+"|'[^']+'|\S+)/)
|
|
230
|
+
const program = quoted ? quoted[0].replace(/^["']|["']$/g, '') : ''
|
|
231
|
+
const base = program.split(/[\\/]+/).filter(Boolean).pop() || ''
|
|
232
|
+
const head = base.replace(/\.exe$/i, '').toLowerCase()
|
|
233
|
+
if (head && /^[a-z][a-z0-9_.-]*$/.test(head) && !/^[a-z]:$/.test(head)) heads.push(head)
|
|
234
|
+
}
|
|
235
|
+
return [...new Set(heads)].sort()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The script a run recipe executes, as a bare name: "run build.mjs" -> build. */
|
|
239
|
+
export function runArtifactName(calls) {
|
|
240
|
+
for (const call of calls || []) {
|
|
241
|
+
if (!call || !call.ok) continue
|
|
242
|
+
if (classify(call).kind !== 'run') continue
|
|
243
|
+
const raw = firstString(call && (call.hint || call.argsHint || call.args)) || ''
|
|
244
|
+
const hit = raw.match(/[A-Za-z0-9_][A-Za-z0-9_.-]*\.(?:mjs|cjs|js|py|go|rs|sh|ps1|java)\b/i)
|
|
245
|
+
if (!hit) continue
|
|
246
|
+
const base = hit[0].split(/[\\/]/).pop()
|
|
247
|
+
return base.replace(/\.[^.]+$/, '')
|
|
248
|
+
}
|
|
249
|
+
return ''
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
201
253
|
|
|
202
254
|
/** The object of a title must name a concrete artifact, not only <placeholders>. */
|
|
203
255
|
export function actionableTitle(title) {
|
|
@@ -219,15 +271,19 @@ export function actionableTitle(title) {
|
|
|
219
271
|
* G7 — refuses captures whose steps or title are not a reusable recipe.
|
|
220
272
|
* Runs after the G1..G6 chain; the caller writes trace.skipReason = reason.
|
|
221
273
|
*/
|
|
222
|
-
export function gateStepQuality(trace, record, config) {
|
|
274
|
+
export function gateStepQuality(trace, record, config, intent) {
|
|
223
275
|
const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }
|
|
224
276
|
const calls = (trace && trace.calls) || []
|
|
225
|
-
|
|
277
|
+
// Two scales: a change is judged by its write/test steps, an invocation
|
|
278
|
+
// recipe by its repeatable executions. mkdir/read never enter either count.
|
|
279
|
+
const effective = intent === 'run' ? runSteps(calls).length : effectiveSteps(calls)
|
|
226
280
|
if (effective < cfg.minEffectiveSteps) {
|
|
281
|
+
const what = intent === 'run' ? '可复用执行(建目录/读文件不计)' : '(只有写操作与测试执行算核心步骤)'
|
|
227
282
|
return {
|
|
228
283
|
ok: false,
|
|
229
|
-
reason: `G7: 有效步骤 ${effective} < ${cfg.minEffectiveSteps}
|
|
284
|
+
reason: `G7: 有效步骤 ${effective} < ${cfg.minEffectiveSteps}${intent === 'run' ? ' 次' : ''}${what}`,
|
|
230
285
|
effective,
|
|
286
|
+
runOnly: intent === 'run',
|
|
231
287
|
}
|
|
232
288
|
}
|
|
233
289
|
const title = String((record && record.title) || '')
|
|
@@ -261,6 +317,46 @@ export function summarizeCalls(calls) {
|
|
|
261
317
|
* A second run that only re-reads and re-runs the tests has the same fingerprint
|
|
262
318
|
* as the first run that scaffolded everything — that is the real "same task".
|
|
263
319
|
*/
|
|
320
|
+
const EXT_KIND = {
|
|
321
|
+
code: ['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'go', 'rs', 'java', 'cs', 'rb', 'php', 'sh', 'ps1', 'cmd', 'bat'],
|
|
322
|
+
image: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg', 'tif', 'tiff', 'ico', 'heic'],
|
|
323
|
+
doc: ['md', 'txt', 'pdf', 'docx', 'doc', 'pptx', 'xlsx', 'csv', 'html', 'css', 'json', 'yml', 'yaml'],
|
|
324
|
+
binary: ['zip', 'gz', 'tgz', 'exe', 'dll', 'whl', 'jar'],
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const KIND_BY_EXT = (() => {
|
|
328
|
+
const map = new Map()
|
|
329
|
+
for (const [kind, exts] of Object.entries(EXT_KIND)) for (const ext of exts) map.set(ext, kind)
|
|
330
|
+
return map
|
|
331
|
+
})()
|
|
332
|
+
|
|
333
|
+
export function artifactKindOf(target) {
|
|
334
|
+
const name = String(target || '').toLowerCase()
|
|
335
|
+
const match = name.match(/\.([a-z0-9]+)$/)
|
|
336
|
+
if (!match) return ''
|
|
337
|
+
return KIND_BY_EXT.get(match[1]) || ''
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function artifactKindsOf(targets) {
|
|
341
|
+
const kinds = new Set()
|
|
342
|
+
for (const target of targets || []) {
|
|
343
|
+
const kind = artifactKindOf(target)
|
|
344
|
+
if (kind) kinds.add(kind)
|
|
345
|
+
}
|
|
346
|
+
return [...kinds].sort()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Do these two runs write fundamentally different kinds of artifact?
|
|
351
|
+
* Only says "conflict" when both sides have evidence and the sets are disjoint.
|
|
352
|
+
*/
|
|
353
|
+
export function artifactTypesConflict(left, right) {
|
|
354
|
+
const a = (left && left.fingerprint && left.fingerprint.kinds) || []
|
|
355
|
+
const b = (right && right.fingerprint && right.fingerprint.kinds) || []
|
|
356
|
+
if (!a.length || !b.length) return false
|
|
357
|
+
return !a.some((kind) => b.includes(kind))
|
|
358
|
+
}
|
|
359
|
+
|
|
264
360
|
export function outcomeFingerprint(calls) {
|
|
265
361
|
const list = calls || []
|
|
266
362
|
const artifactOf = (call) => {
|
|
@@ -276,9 +372,12 @@ export function outcomeFingerprint(calls) {
|
|
|
276
372
|
// A run that failed and then went green still ends green; the failure itself is
|
|
277
373
|
// captured as a reverse example, not as a reason to refuse the merge.
|
|
278
374
|
const green = testCalls.length > 0 && testCalls[testCalls.length - 1].ok === true
|
|
279
|
-
const
|
|
375
|
+
const allArtifacts = writes.length ? writes : reads
|
|
376
|
+
const artifacts = allArtifacts.filter(isCode)
|
|
280
377
|
return {
|
|
281
378
|
targets: [...new Set(artifacts)].sort(),
|
|
379
|
+
commands: commandHeads(list),
|
|
380
|
+
kinds: artifactKindsOf(allArtifacts),
|
|
282
381
|
tests: [...new Set(tests)].sort(),
|
|
283
382
|
green,
|
|
284
383
|
verificationOnly: writes.length === 0,
|
|
@@ -290,6 +389,8 @@ export function fingerprintsMatch(left, right) {
|
|
|
290
389
|
const a = left && left.fingerprint
|
|
291
390
|
const b = right && right.fingerprint
|
|
292
391
|
if (!a || !b || !a.green || !b.green) return false
|
|
392
|
+
// Same name, different artifact type is not the same outcome.
|
|
393
|
+
if (artifactTypesConflict(left, right)) return false
|
|
293
394
|
const have = new Set(a.targets || [])
|
|
294
395
|
const want = new Set(b.targets || [])
|
|
295
396
|
if (!have.size || !want.size) return false
|
|
@@ -375,7 +476,7 @@ export function inferTaskType(calls, cwd) {
|
|
|
375
476
|
return 'workspace/general'
|
|
376
477
|
}
|
|
377
478
|
|
|
378
|
-
export function shouldCapture(trace, counts, config) {
|
|
479
|
+
export function shouldCapture(trace, counts, config, intent) {
|
|
379
480
|
const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }
|
|
380
481
|
const calls = (trace && trace.calls) || []
|
|
381
482
|
const successes = calls.filter((call) => call && call.ok)
|
|
@@ -394,7 +495,11 @@ export function shouldCapture(trace, counts, config) {
|
|
|
394
495
|
const tests = successes.filter((call) => isTestCall(call))
|
|
395
496
|
const testFailures = calls.filter((call) => call && !call.ok && isTestCall(call))
|
|
396
497
|
const verifiedGreen = tests.length > 0 && testFailures.length === 0
|
|
397
|
-
|
|
498
|
+
// A run recipe is legitimate evidence on its own: nobody wrote anything, but
|
|
499
|
+
// the execution was repeated and is worth replaying.
|
|
500
|
+
const executions = intent === 'run' ? runSteps(calls) : []
|
|
501
|
+
const runOnly = intent === 'run' && !writes.length && !recoveries.length && !verifiedGreen
|
|
502
|
+
if (!writes.length && !recoveries.length && !verifiedGreen && !(runOnly && executions.length >= 2)) {
|
|
398
503
|
return { ok: false, reason: 'G3: 无成功写操作、无失败后成功,且不是全绿验证轨迹' }
|
|
399
504
|
}
|
|
400
505
|
|
|
@@ -404,7 +509,14 @@ export function shouldCapture(trace, counts, config) {
|
|
|
404
509
|
if ((counts && counts.autoTotal) >= cfg.libraryLimit) {
|
|
405
510
|
return { ok: false, reason: `G5: 自动经验库已达上限 ${cfg.libraryLimit}` }
|
|
406
511
|
}
|
|
407
|
-
return {
|
|
512
|
+
return {
|
|
513
|
+
ok: true,
|
|
514
|
+
writes,
|
|
515
|
+
recoveries,
|
|
516
|
+
runOnly,
|
|
517
|
+
executions: executions.length,
|
|
518
|
+
verificationOnly: !writes.length && !recoveries.length,
|
|
519
|
+
}
|
|
408
520
|
}
|
|
409
521
|
|
|
410
522
|
function verbsOf(taskType) {
|
|
@@ -423,7 +535,12 @@ export function workspaceName(cwd) {
|
|
|
423
535
|
|
|
424
536
|
const GENERIC_DIRS = new Set([
|
|
425
537
|
'users', 'home', 'desktop', 'documents', 'downloads', 'src', 'test', 'tests',
|
|
426
|
-
'lib', 'node_modules', 'bin', 'tmp', 'temp', 'app', 'apps', 'workspace',
|
|
538
|
+
'lib', 'node_modules', 'bin', 'tmp', 'temp', 'app', 'apps', 'workspace',
|
|
539
|
+
'workspaces',
|
|
540
|
+
// Tool-chain directories: every recipe on this machine touches them, so they
|
|
541
|
+
// say nothing about *which project* a record belongs to.
|
|
542
|
+
'node', 'python', 'python3', 'npm', 'pnpm', 'npx', 'yarn', 'git', 'pwsh',
|
|
543
|
+
'deno', 'bun', 'runtime', 'runtimes', 'dependencies', 'dsh', 'windows',
|
|
427
544
|
])
|
|
428
545
|
|
|
429
546
|
/**
|
|
@@ -481,9 +598,14 @@ export function scopeOf(record) {
|
|
|
481
598
|
}
|
|
482
599
|
|
|
483
600
|
/** Two records describe different targets when both name a project dir and the names disagree. */
|
|
601
|
+
/** Scope tokens that actually name a project, not the tool-chain around it. */
|
|
602
|
+
export function projectScope(record) {
|
|
603
|
+
return scopeOf(record).filter((item) => item.length >= 2 && !GENERIC_DIRS.has(item))
|
|
604
|
+
}
|
|
605
|
+
|
|
484
606
|
export function scopeConflicts(existing, draft) {
|
|
485
|
-
const left =
|
|
486
|
-
const right =
|
|
607
|
+
const left = projectScope(existing)
|
|
608
|
+
const right = projectScope(draft)
|
|
487
609
|
if (!left.length || !right.length) return false
|
|
488
610
|
return !left.some((item) => right.includes(item))
|
|
489
611
|
}
|
|
@@ -505,14 +627,29 @@ export function buildCandidate(trace, info, config) {
|
|
|
505
627
|
const writes = calls.filter((call) => call && call.ok && classify(call).kind === 'write')
|
|
506
628
|
const runs = calls.filter((call) => call && call.ok && classify(call).kind === 'run')
|
|
507
629
|
|
|
630
|
+
const runOnly = Boolean(info && info.runOnly) || (!writes.length && runs.length > 0)
|
|
508
631
|
const primary = writes.length ? classify(writes[0]).target : runs.length ? classify(runs[0]).target : ''
|
|
632
|
+
const scriptName = runOnly ? runArtifactName(calls) : ''
|
|
509
633
|
const verb = writes.length ? '写' : '跑通'
|
|
510
634
|
// Prefer a concrete artifact name (raw basename, before path normalization):
|
|
511
635
|
// "<path>/<name>.js" cannot name what was produced, "slugify.js" can.
|
|
512
636
|
const named = writes
|
|
513
637
|
.map((call) => artifactName(call))
|
|
514
638
|
.find((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) && /[A-Za-z0-9]{2,}/.test(name))
|
|
515
|
-
const title = clip(
|
|
639
|
+
const title = clip(
|
|
640
|
+
runOnly
|
|
641
|
+
? scriptName
|
|
642
|
+
? `跑 ${scriptName}`
|
|
643
|
+
: primary
|
|
644
|
+
? `跑 ${primary}`
|
|
645
|
+
: verbsOf(taskType)
|
|
646
|
+
: named
|
|
647
|
+
? `新增 ${named}`
|
|
648
|
+
: primary
|
|
649
|
+
? `${verb} ${primary}`
|
|
650
|
+
: verbsOf(taskType),
|
|
651
|
+
110,
|
|
652
|
+
)
|
|
516
653
|
const place = workspaceName(info && info.cwd)
|
|
517
654
|
const when = clip(place ? `在 ${place} 里 ${verbsOf(taskType)}` : `当需要${verbsOf(taskType)}时`, 160)
|
|
518
655
|
const keywords = [...new Set([taskType, ...steps.map((step) => step.split(' ')[1] || '').filter(Boolean)])]
|
package/lib/decay.js
CHANGED
|
@@ -127,7 +127,7 @@ export function selectEvictions(records, now = Date.now(), config = {}) {
|
|
|
127
127
|
return { ...base, triggered: false, evictions: [], reason: `未达容量阈值 ${threshold}(当前 ${list.length})` }
|
|
128
128
|
}
|
|
129
129
|
const eligible = list
|
|
130
|
-
.filter((record) => record && record.status !== 'verified' && !isPinned(record))
|
|
130
|
+
.filter((record) => record && record.status !== 'verified' && record.status !== 'rejected' && !isPinned(record))
|
|
131
131
|
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
132
132
|
.filter((entry) => entry.info.dormant)
|
|
133
133
|
.sort((left, right) => left.info.rank - right.info.rank)
|
|
@@ -178,9 +178,13 @@ export function decayLine(record, now = Date.now(), config = {}) {
|
|
|
178
178
|
|
|
179
179
|
export function renderDecayReport(store, now = Date.now(), config = {}) {
|
|
180
180
|
const records = Array.isArray(store && store.experiences) ? store.experiences : []
|
|
181
|
-
|
|
181
|
+
// Rejected records are kept as an audit trail: they cannot be recalled, merged
|
|
182
|
+
// or evicted, so counting them would only distort the capacity numbers.
|
|
183
|
+
const hiddenRejected = records.filter((record) => record && record.status === 'rejected').length
|
|
184
|
+
const live = records.filter((record) => record && record.status !== 'rejected')
|
|
185
|
+
const proposal = selectEvictions(live, now, config)
|
|
182
186
|
const counts = { active: 0, dormant: 0, pinned: 0 }
|
|
183
|
-
const sorted =
|
|
187
|
+
const sorted = live
|
|
184
188
|
.map((record) => ({ record, info: stateOf(record, now, config) }))
|
|
185
189
|
.sort((left, right) => {
|
|
186
190
|
if (left.info.pinned !== right.info.pinned) return left.info.pinned ? 1 : -1
|
|
@@ -193,7 +197,8 @@ export function renderDecayReport(store, now = Date.now(), config = {}) {
|
|
|
193
197
|
const eligible = new Set(proposal.evictions.map((item) => item.id))
|
|
194
198
|
const top = sorted.filter((entry) => !entry.info.pinned).slice(0, 5)
|
|
195
199
|
const lines = [
|
|
196
|
-
`[M3.3 衰减报告] 库 ${
|
|
200
|
+
`[M3.3 衰减报告] 库 ${live.length} 条 · active ${counts.active} / dormant ${counts.dormant} / pinned ${counts.pinned}`,
|
|
201
|
+
...(hiddenRejected ? [`已排除 ${hiddenRejected} 条 rejected(审计留档,不参与排序与容量;\/memory list --all 查看)`] : []),
|
|
197
202
|
`容量: ${proposal.count}/${proposal.libraryLimit}(阈值 ${proposal.threshold} · 比例 ${num(config.capacityRatio, DECAY_DEFAULTS.capacityRatio)})· ${proposal.triggered ? '已达阈值' : '未达阈值'}`,
|
|
198
203
|
proposal.triggered
|
|
199
204
|
? `建议归档 ${proposal.evictions.length} 条(最多 ${num(config.evictBatch, DECAY_DEFAULTS.evictBatch)} 条/次,按 rank 升序):`
|
package/lib/recall.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Retrieval scoring: lexical tokens + a small hand-written feature layer.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: a task description and a stored recipe often share *meaning*
|
|
4
|
+
// while sharing almost no words ("跑一个会踩坑的 .mjs 脚本,node 报错" vs
|
|
5
|
+
// "pwsh / node.exe / load_workspace_dependencies"). Pure lexical coverage then
|
|
6
|
+
// scores near zero and the recipe is never recalled.
|
|
7
|
+
//
|
|
8
|
+
// Two mechanisms, both synchronous and dependency-free:
|
|
9
|
+
// 1. stop words — colloquial fillers ("随便", "故意", "测试场景") must not dilute coverage
|
|
10
|
+
// 2. features — canonical buckets ("#node", "#error", "#shell") that merge synonyms
|
|
11
|
+
//
|
|
12
|
+
// Features are derived from the raw text *before* stop-word removal, so a task
|
|
13
|
+
// that explicitly says "测试" still produces "#test" even though "测试" is dropped
|
|
14
|
+
// as a token.
|
|
15
|
+
|
|
16
|
+
export const STOP_WORDS = new Set([
|
|
17
|
+
// function words
|
|
18
|
+
'的', '了', '和', '与', '在', '是', '把', '并', '就', '还', '也', '有', '用', '要', '中',
|
|
19
|
+
'这个', '那个', '一个', '一下', '我们', '你们', '可以', '需要', '请', '帮', '让我', '给我',
|
|
20
|
+
'然后', '现在', '已经', '还是', '就是', '不要', '不用', '没有', '什么', '怎么', '这样',
|
|
21
|
+
'那样', '但是', '因为', '所以', '如果', '或者', '以及', '并且', '而且', '之后', '之前',
|
|
22
|
+
// colloquial fillers that carry no retrieval signal
|
|
23
|
+
'随便', '随便写', '故意', '特意', '场景', '测试场景', '试试', '尝试', '看看', '跑一个',
|
|
24
|
+
'随便跑', '简单', '复杂', '大概', '可能', '应该', '一会', '等下',
|
|
25
|
+
// english
|
|
26
|
+
'the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'is', 'are', 'be',
|
|
27
|
+
'it', 'this', 'that', 'then', 'use', 'run', 'can', 'will', 'should', 'just', 'some',
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
// Canonical bucket -> patterns that should activate it. Keep this list small and
|
|
31
|
+
// conservative: a bucket that fires on everything destroys ranking signal.
|
|
32
|
+
export const FEATURES = {
|
|
33
|
+
node: [/node(?:\.exe)?/, /\bmjs\b/, /\.mjs\b/, /\besm\b/, /内置\s*node/, /nodejs/],
|
|
34
|
+
shell: [/pwsh/, /powershell/, /\bshell\b/, /命令行/, /终端/, /cmd\b/],
|
|
35
|
+
error: [/报错/, /错误/, /\berror\b/, /exception/, /抛错/, /失败/, /\bfail/, /stack/],
|
|
36
|
+
module: [/模块/, /\bmodule\b/, /import\s/, /require\(/, /导出/, /export\s/],
|
|
37
|
+
test: [/测试/, /\btest\b/, /用例/, /\bspec\b/, /断言/, /assert/],
|
|
38
|
+
file: [/文件/, /脚本/, /\bfile\b/, /\.js\b/, /\.mjs\b/, /\.json\b/],
|
|
39
|
+
path: [/路径/, /目录/, /\bpath\b/, /folder/, /文件夹/],
|
|
40
|
+
python: [/python/, /\.py\b/, /pip\b/],
|
|
41
|
+
git: [/\bgit\b/, /提交/, /分支/, /仓库/, /commit/],
|
|
42
|
+
// Intent buckets: "run this script" and "add a function" are different tasks
|
|
43
|
+
// even when they mention the same files. Without these, a noisy description
|
|
44
|
+
// matches whichever record happens to share more generic nouns.
|
|
45
|
+
run: [/跑/, /运行/, /执行/, /\brun\b/, /execute/, /启动/],
|
|
46
|
+
write: [/新增/, /添加/, /创建/, /写入/, /\bwrite\b/, /\bcreate\b/, /实现/, /修改/, /重构/, /补/],
|
|
47
|
+
debug: [/排查/, /踩坑/, /修复/, /定位/, /\bdebug\b/, /为什么/, /失败原因/],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const FEATURE_PREFIX = '#'
|
|
51
|
+
|
|
52
|
+
export function features(text) {
|
|
53
|
+
const source = String(text || '').toLowerCase()
|
|
54
|
+
const out = new Set()
|
|
55
|
+
for (const [name, patterns] of Object.entries(FEATURES)) {
|
|
56
|
+
if (patterns.some((re) => re.test(source))) out.add(FEATURE_PREFIX + name)
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function tokenize(text) {
|
|
62
|
+
const out = new Set()
|
|
63
|
+
const source = String(text || '').toLowerCase()
|
|
64
|
+
for (const match of source.matchAll(/[a-z0-9_][a-z0-9_.\-/]*/g)) {
|
|
65
|
+
const token = match[0]
|
|
66
|
+
if (token.length >= 2 && !STOP_WORDS.has(token)) {
|
|
67
|
+
out.add(token)
|
|
68
|
+
// Index the basename too, so "truncate.test.mjs" matches "test/truncate.test.mjs".
|
|
69
|
+
const slash = token.lastIndexOf('/')
|
|
70
|
+
if (slash > 0 && token.length - slash > 2) out.add(token.slice(slash + 1))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const cjk = source.replace(/[^\u4e00-\u9fff]+/g, ' ')
|
|
74
|
+
for (const chunk of cjk.split(' ')) {
|
|
75
|
+
for (let i = 0; i + 1 < chunk.length; i++) {
|
|
76
|
+
const bigram = chunk.slice(i, i + 2)
|
|
77
|
+
if (!STOP_WORDS.has(bigram)) out.add(bigram)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Tokens + feature buckets for one text (a query or a stored profile). */
|
|
84
|
+
export function expand(text) {
|
|
85
|
+
const out = tokenize(text)
|
|
86
|
+
for (const feature of features(text)) out.add(feature)
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The searchable profile of a stored experience. */
|
|
91
|
+
export function profileText(experience) {
|
|
92
|
+
if (!experience) return ''
|
|
93
|
+
// Pitfalls and counter-examples are usually the only place a record names the
|
|
94
|
+
// concrete failure ("MODULE_NOT_FOUND", "CommandNotFoundException"). Scoring
|
|
95
|
+
// without them ranks "add a file" chores above the actual troubleshooting note.
|
|
96
|
+
return [
|
|
97
|
+
experience.title,
|
|
98
|
+
experience.taskType,
|
|
99
|
+
experience.when,
|
|
100
|
+
(experience.keywords || []).join(' '),
|
|
101
|
+
(experience.pitfalls || []).join(' '),
|
|
102
|
+
(experience.reverse_examples || []).join(' '),
|
|
103
|
+
].filter(Boolean).join(' ')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const round = (value) => Math.round(value * 1000) / 1000
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Document frequencies across the whole library. Rare tokens ("node.exe",
|
|
110
|
+
* "MODULE_NOT_FOUND") discriminate; common ones ("报错", "模块") must not.
|
|
111
|
+
* Without this, a noisy task description lifts every record at once.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Which kind of task is this: running something, or writing something?
|
|
115
|
+
* Empty means "cannot tell" and never vetoes anything.
|
|
116
|
+
*/
|
|
117
|
+
export function intentOf(experienceOrText) {
|
|
118
|
+
const text =
|
|
119
|
+
typeof experienceOrText === 'string' ? experienceOrText : profileText(experienceOrText)
|
|
120
|
+
const found = features(text)
|
|
121
|
+
const run = found.has('#run')
|
|
122
|
+
const write = found.has('#write')
|
|
123
|
+
if (run && !write) return 'run'
|
|
124
|
+
if (write && !run) return 'write'
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* "run the image pipeline" and "add a helper function" are different tasks even
|
|
130
|
+
* when they share every noun; only verdicts with evidence on both sides count.
|
|
131
|
+
*/
|
|
132
|
+
export function intentConflict(left, right) {
|
|
133
|
+
const a = intentOf(left)
|
|
134
|
+
const b = intentOf(right)
|
|
135
|
+
return Boolean(a && b && a !== b)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function corpus(experiences) {
|
|
139
|
+
const df = new Map()
|
|
140
|
+
let n = 0
|
|
141
|
+
for (const experience of experiences || []) {
|
|
142
|
+
n += 1
|
|
143
|
+
for (const token of expand(profileText(experience))) df.set(token, (df.get(token) || 0) + 1)
|
|
144
|
+
}
|
|
145
|
+
return { n, df }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function idf(token, c) {
|
|
149
|
+
if (!c || !c.n) return 1
|
|
150
|
+
const d = c.df.get(token) || 0
|
|
151
|
+
return Math.log(1 + (c.n - d + 0.5) / (d + 0.5))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Feature buckets are hand-made and rare by construction: worth a little more than a plain token. */
|
|
155
|
+
const FEATURE_WEIGHTS = {
|
|
156
|
+
'#error': 3,
|
|
157
|
+
'#node': 2.6,
|
|
158
|
+
'#module': 2.2,
|
|
159
|
+
'#shell': 2,
|
|
160
|
+
'#debug': 2,
|
|
161
|
+
'#run': 1.8,
|
|
162
|
+
'#write': 1.8,
|
|
163
|
+
'#test': 1.4,
|
|
164
|
+
'#file': 1.2,
|
|
165
|
+
'#path': 1.2,
|
|
166
|
+
'#python': 1.4,
|
|
167
|
+
'#git': 1.4,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const featureWeight = (token) =>
|
|
171
|
+
token.startsWith(FEATURE_PREFIX) ? FEATURE_WEIGHTS[token] || 1.4 : 1
|
|
172
|
+
|
|
173
|
+
// A shared failure signal is worth more than any amount of shared nouns: the
|
|
174
|
+
// user is asking for help with the same class of breakage.
|
|
175
|
+
const CRITICAL_FEATURES = ['#error', '#node', '#module']
|
|
176
|
+
|
|
177
|
+
/** CJK bigrams are noisy: 脚本 / 模块 / 报错 collide across unrelated tasks. */
|
|
178
|
+
const scriptWeight = (token) => (/^[\u4e00-\u9fff]+$/.test(token) ? 0.45 : 1)
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Score a stored profile against an expanded query set.
|
|
182
|
+
* 0.75 * idf-weighted share of the query the profile covers
|
|
183
|
+
* 0.25 * unweighted share of the profile the query covers
|
|
184
|
+
* Both terms are bounded, so the result is always within [0, 1].
|
|
185
|
+
*/
|
|
186
|
+
export function score(profile, queryTokens, c) {
|
|
187
|
+
const expTokens = expand(profile)
|
|
188
|
+
if (!queryTokens || !queryTokens.size || !expTokens.size) return 0
|
|
189
|
+
let weightSum = 0
|
|
190
|
+
let weightHit = 0
|
|
191
|
+
for (const token of queryTokens) {
|
|
192
|
+
const weight = idf(token, c) * featureWeight(token) * scriptWeight(token)
|
|
193
|
+
weightSum += weight
|
|
194
|
+
if (expTokens.has(token)) weightHit += weight
|
|
195
|
+
}
|
|
196
|
+
const queryCoverage = weightSum ? weightHit / weightSum : 0
|
|
197
|
+
let expHits = 0
|
|
198
|
+
for (const token of expTokens) if (queryTokens.has(token)) expHits++
|
|
199
|
+
const expCoverage = expHits / expTokens.size
|
|
200
|
+
let critical = 0
|
|
201
|
+
for (const feature of CRITICAL_FEATURES) {
|
|
202
|
+
if (queryTokens.has(feature) && expTokens.has(feature)) critical += 1
|
|
203
|
+
}
|
|
204
|
+
const criticalBonus = critical ? 0.08 * critical : 0
|
|
205
|
+
return round(Math.min(1, 0.75 * queryCoverage + 0.25 * expCoverage + criticalBonus))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Human-readable breakdown for `/memory why` and `experience_debug`. */
|
|
209
|
+
export function explain(profile, queryText) {
|
|
210
|
+
const queryTokens = expand(queryText)
|
|
211
|
+
const expTokens = expand(profile)
|
|
212
|
+
const shared = [...queryTokens].filter((t) => expTokens.has(t))
|
|
213
|
+
return {
|
|
214
|
+
score: score(profile, queryTokens),
|
|
215
|
+
queryTokens: [...queryTokens].slice(0, 40),
|
|
216
|
+
expTokens: [...expTokens].slice(0, 40),
|
|
217
|
+
shared: shared.slice(0, 20),
|
|
218
|
+
features: shared.filter((t) => t.startsWith(FEATURE_PREFIX)),
|
|
219
|
+
}
|
|
220
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Durable store I/O, split out of index.js so the reload path is testable.
|
|
2
|
+
//
|
|
3
|
+
// The bug this exists to prevent: `loadStore()` used to rebuild only
|
|
4
|
+
// {version,nextId,nextInjectionId,experiences,injections,turns}, so every
|
|
5
|
+
// reload dropped `archive` and `evictions` — and the next persist() rewrote
|
|
6
|
+
// them out of the file, silently losing archived records.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
|
|
10
|
+
export function emptyStore() {
|
|
11
|
+
return {
|
|
12
|
+
version: 1,
|
|
13
|
+
nextId: 1,
|
|
14
|
+
nextInjectionId: 1,
|
|
15
|
+
experiences: [],
|
|
16
|
+
injections: [],
|
|
17
|
+
turns: [],
|
|
18
|
+
archive: [],
|
|
19
|
+
evictions: [],
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const array = (value) => (Array.isArray(value) ? value : [])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rebuild a store from parsed JSON without dropping fields.
|
|
27
|
+
* Unknown extra fields are preserved verbatim on purpose: a plugin upgrade that
|
|
28
|
+
* adds a table must not lose it when an older build reloads the file.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeStore(parsed) {
|
|
31
|
+
if (!parsed || typeof parsed !== 'object') throw new Error('not an object')
|
|
32
|
+
const known = {
|
|
33
|
+
version: Math.max(2, Number(parsed.version || 1)),
|
|
34
|
+
nextId: Number(parsed.nextId) || 1,
|
|
35
|
+
nextInjectionId: Number(parsed.nextInjectionId) || 1,
|
|
36
|
+
experiences: array(parsed.experiences),
|
|
37
|
+
injections: array(parsed.injections),
|
|
38
|
+
turns: array(parsed.turns),
|
|
39
|
+
archive: array(parsed.archive),
|
|
40
|
+
evictions: array(parsed.evictions),
|
|
41
|
+
}
|
|
42
|
+
return { ...parsed, ...known }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read a store file.
|
|
47
|
+
* @returns {{ store: object, error?: Error, missing?: boolean }}
|
|
48
|
+
*/
|
|
49
|
+
export function readStore(file) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(file, 'utf8')
|
|
52
|
+
return { store: normalizeStore(JSON.parse(raw)) }
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error && error.code === 'ENOENT') return { store: emptyStore(), missing: true }
|
|
55
|
+
return { store: emptyStore(), error }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Atomic-ish write: temp file + rename, UTF-8, no BOM. */
|
|
60
|
+
export function writeStore(file, store) {
|
|
61
|
+
const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')))
|
|
62
|
+
if (dir) fs.mkdirSync(dir, { recursive: true })
|
|
63
|
+
const tmp = `${file}.tmp`
|
|
64
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8')
|
|
65
|
+
fs.renameSync(tmp, file)
|
|
66
|
+
}
|