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/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
- const effective = effectiveSteps(calls)
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 artifacts = (writes.length ? writes : reads).filter(isCode)
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
- if (!writes.length && !recoveries.length && !verifiedGreen) {
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 { ok: true, writes, recoveries, verificationOnly: !writes.length && !recoveries.length }
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', 'workspaces',
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 = scopeOf(existing)
486
- const right = scopeOf(draft)
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(named ? `新增 ${named}` : primary ? `${verb} ${primary}` : verbsOf(taskType), 110)
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
- const proposal = selectEvictions(records, now, config)
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 = records
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 衰减报告] 库 ${records.length} 条 · active ${counts.active} / dormant ${counts.dormant} / pinned ${counts.pinned}`,
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-experience",
3
- "version": "1.0.5",
3
+ "version": "1.0.14",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"