@yolk_vat-y/dsh-project-memory 0.5.5 → 0.5.7

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/src/readiness.js CHANGED
@@ -16,6 +16,9 @@
16
16
  * @module dsh-project-memory/readiness
17
17
  */
18
18
 
19
+ import { FALLBACK_OPS, WEAK_OPS, activityFromText, opForLegacyAction } from './ops.js'
20
+ import { CJK_RANGE, tokenize } from './util/search.js'
21
+
19
22
  /**
20
23
  * 动作词典:动作 id → 匹配模式。命令形态与人类意图词都归一成同一套 id,
21
24
  * 于是"你顺便提交一下"与 `git commit -m x` 对 trigger 是同一件事。
@@ -79,8 +82,37 @@ export function buildReadinessContext(input = {}) {
79
82
  const humanText = String(input.humanText ?? input.query ?? '')
80
83
  const actionText = String(input.actionText || '')
81
84
  const actions = new Set([...(input.actions || []), ...detectActions(humanText), ...detectActions(actionText)])
82
- const paths = new Set([...(input.paths || []), ...extractPaths(actionText), ...extractPaths(humanText)])
83
- return { humanText, actionText, actions: [...actions], paths: [...paths] }
85
+ // 人类消息里提到的路径 = **动手前的写意图**("帮我改 src/x.js");动作文本里的路径不区分读写,
86
+ // 读一个文件不该算写目标(否则 `read README.md` 会命中 writes:['README.md'])。
87
+ // 两者对外仍合并成 `paths`(兼容既有语义),写通道单看 humanPaths。
88
+ // 调用方若已带 humanPaths(injectForStep 构建后再传给 buildInjection),以它为准,
89
+ // 否则退回 paths —— 避免把上一次算出来的并集(含读路径)再当写意图。
90
+ const humanPaths = new Set([
91
+ ...(Array.isArray(input.humanPaths) ? input.humanPaths : (input.paths || [])),
92
+ ...extractPaths(humanText),
93
+ ])
94
+ const paths = new Set([...humanPaths, ...extractPaths(actionText)])
95
+ // 动作平面(S1):结构化调用优先;没有结构化调用时对 actionText 跑一遍 shell 规则兜底。
96
+ const fromText = activityFromText(actionText)
97
+ const ops = new Set([...(input.ops || []), ...fromText.ops])
98
+ for (const a of actions) {
99
+ const op = opForLegacyAction(a)
100
+ if (op) ops.add(op)
101
+ }
102
+ const targets = new Set([...(input.targets || []), ...fromText.targets])
103
+ const hosts = new Set([...(input.hosts || []), ...fromText.hosts])
104
+ return {
105
+ humanText,
106
+ actionText,
107
+ actions: [...actions],
108
+ paths: [...paths],
109
+ humanPaths: [...humanPaths],
110
+ ops: [...ops],
111
+ targets: [...targets],
112
+ hosts: [...hosts],
113
+ intent: intentText(humanText),
114
+ tags: Array.isArray(input.tags) ? [...input.tags] : [],
115
+ }
84
116
  }
85
117
 
86
118
  /**
@@ -145,12 +177,12 @@ export function backfillDerivedTriggers(doc) {
145
177
  }
146
178
 
147
179
  /**
148
- * 提示通道的查询文本:剔除 1–2 个字符的拉丁 token。
180
+ * 提示通道的查询文本:剔除 1–3 个字符的拉丁 token。
149
181
  *
150
- * 为什么:`PR` / `CI` / `OS` 这类缩写太短、歧义太大,一个巧合命中就能当上该层最高分,
151
- * 于是以 `relative:1.00` 混进上下文(实测:人类消息里的 "PR" 把一条 task-tools 越权 lesson
152
- * 顶到了提示位)。内容词(≥3 字符的拉丁标识符、CJK 词)不受影响;
153
- * authored trigger 通道完全不走这里,确定性匹配保持字面语义。
182
+ * 为什么:`PR` / `CI` / `OS` / `WSL` / `npm` 这类缩写太短、歧义太大,一个巧合命中就能当上
183
+ * 该层最高分,于是以 `relative:1.00` 混进上下文(实测:人类消息里的 "PR" 把一条 task-tools
184
+ * 越权 lesson 顶到了提示位;"wsl" 又把一条 pnpm lesson 顶到了"改文件时间戳"的任务里)。
185
+ * 内容词(≥4 字符的拉丁标识符、CJK 词)不受影响;authored trigger 通道完全不走这里。
154
186
  * @param {string} text - 人类消息或工具参数。
155
187
  * @returns {string} 过滤后的查询文本。
156
188
  */
@@ -159,7 +191,7 @@ export function hintQueryText(text) {
159
191
  .split(/\s+/)
160
192
  // 路径形态的 token 原样保留(src/util/fs.js 里的 fs / js 是有效证据),
161
193
  // 只对独立词做缩写剔除。
162
- .map((w) => (/[/.]/.test(w) ? w : w.replace(/\b[A-Za-z0-9_]{1,2}\b/g, ' ')))
194
+ .map((w) => (/[/.]/.test(w) ? w : w.replace(/\b[A-Za-z0-9_]{1,3}\b/g, ' ')))
163
195
  .join(' ')
164
196
  .replace(/\s+/g, ' ')
165
197
  .trim()
@@ -172,42 +204,275 @@ function globToRegExp(pattern) {
172
204
  }
173
205
 
174
206
  /**
175
- * trigger 命中判定(确定性,无打分)。
176
- * @param {object|null} trigger - `{ keywords?, symbols?, actions?, paths?, scope? }`。
177
- * @param {object} ctx - {@link buildReadinessContext} 的结果。
178
- * @returns {string|null} 命中原因(`keyword:…` / `symbol:…` / `action:…` / `path:…`),未命中为 null。
207
+ * 剥离**引用内容**:代码块、行内代码、引号内的路径/文件名、裸路径。
208
+ *
209
+ * 为什么必须剥:人类消息里的文件名是**数据**,不是意图。实测中"石啸天-LLM记忆方向调研.pptx"
210
+ * 这个文件名让一条"做调研要先扫 curated 列表"的经验在改文件时间戳的任务里被注入。
211
+ * @param {string} text - 人类消息原文
212
+ * @returns {string} 只保留意图文字的版本
179
213
  */
180
- export function matchTrigger(trigger, ctx) {
181
- if (!trigger) return null
182
- const haystack = `${ctx?.humanText || ''}\n${ctx?.actionText || ''}`
183
- const lower = haystack.toLowerCase()
184
- for (const kw of trigger.keywords || []) {
185
- const k = String(kw || '')
186
- if (k && lower.includes(k.toLowerCase())) return `keyword:${k}`
214
+ export function intentText(text) {
215
+ return String(text || '')
216
+ .replace(/```[\s\S]*?```/g, ' ')
217
+ .replace(/`[^`]*`/g, ' ')
218
+ .replace(/"[^"\n]*"/g, ' ')
219
+ .replace(/“[^”\n]*”/g, ' ')
220
+ .replace(/[A-Za-z]:\\[^\s"']*/g, ' ')
221
+ .replace(/(?:[A-Za-z0-9_.@-]+\/)+[A-Za-z0-9_.@-]+/g, ' ')
222
+ // 带扩展名的**非 ASCII 文件名**(`石啸天-记忆方向调研.pptx` / `docs/面试演示-王金鹏.md`):
223
+ // 只挡 ASCII 路径不够——中文文件名整块留下,"调研""面试"这类子串照样触发 when.intents,
224
+ // 正是引号剥离要防的那类假阳性。ASCII 裸名(如 de-TODO.md)保持原语义,不在这里动。
225
+ .replace(/\S*[^\x00-\x7F]\S*\.[A-Za-z][A-Za-z0-9]{0,7}\b/g, ' ')
226
+ // 仓库里的裸文件名(README / CHANGELOG …)也是语料,不是意图
227
+ .replace(/\b(?:README|CHANGELOG|LICENSE|AGENTS|CONTRIBUTING|Dockerfile|Makefile)\b/g, ' ')
228
+ .replace(/\s+/g, ' ')
229
+ .trim()
230
+ }
231
+
232
+ /**
233
+ * 意图词是否值得作为触发信号(S2 的准入过滤)。
234
+ *
235
+ * 规则来自实测:`rm` 命中 `dcterms`、`ppt` 命中 `pptx`、`ms` 命中任何含 ms 的词——短拉丁词
236
+ * 是假阳性制造机;含 `_`/`.`/`/` 的是标识符或路径,属于语料平面,不是意图。
237
+ */
238
+ export function isIntentWord(word) {
239
+ const s = String(word || '').trim()
240
+ if (!s) return false
241
+ if (CJK_RANGE.test(s)) return s.length >= 2
242
+ if (s.length < 5) return false
243
+ if (/[_./\\*]/.test(s)) return false
244
+ if (/\s/.test(s)) return s.length >= 8
245
+ return true
246
+ }
247
+
248
+ const GLOB_RE = /\*/
249
+ /** 扩展名 glob(`*.pptx`)与泛名 glob(`README*`):只能撒谎,不能收窄。 */
250
+ export function isDroppableGlob(pattern) {
251
+ const p = String(pattern || '')
252
+ if (!GLOB_RE.test(p)) return false
253
+ if (/^\*\.\w+$/.test(p)) return true
254
+ if (/^[A-Za-z][A-Za-z0-9_-]*\*$/.test(p)) return true
255
+ return false
256
+ }
257
+
258
+ /**
259
+ * 意图词命中:CJK 用包含,拉丁用词边界(避免 `ppt` 命中 `pptx`)。
260
+ * @param {string} word
261
+ * @param {string} text - 已经是 {@link intentText} 处理过的意图文本
262
+ */
263
+ export function matchIntent(word, text) {
264
+ const w = String(word || '').trim()
265
+ const hay = String(text || '')
266
+ if (!w || !hay) return false
267
+ if (CJK_RANGE.test(w)) return w.length >= 2 && hay.includes(w)
268
+ if (w.length < 4) return false
269
+ const esc = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
270
+ return new RegExp(`(^|[^A-Za-z0-9_])${esc}([^A-Za-z0-9_]|$)`, 'i').test(hay)
271
+ }
272
+
273
+ function matchAnyPath(pattern, targets) {
274
+ const p = String(pattern || '')
275
+ if (!p) return false
276
+ const re = globToRegExp(p)
277
+ return (targets || []).some((cand) => {
278
+ const c = String(cand || '')
279
+ if (!c) return false
280
+ return re.test(c) || re.test(c.split(/[\\/]/).pop() || '')
281
+ })
282
+ }
283
+
284
+ /** guard 是**收窄**条件:全部满足才放行;它自己不能触发任何东西。 */
285
+ function guardPass(guard, ctx) {
286
+ if (!guard || typeof guard !== 'object') return true
287
+ const targets = ctx?.targets || []
288
+ if (Array.isArray(guard.paths) && guard.paths.length && !guard.paths.some((p) => matchAnyPath(p, targets))) return false
289
+ if (Array.isArray(guard.not_paths) && guard.not_paths.some((p) => matchAnyPath(p, targets))) return false
290
+ if (Array.isArray(guard.hosts) && guard.hosts.length && !guard.hosts.some((h) => (ctx?.hosts || []).includes(String(h)))) return false
291
+ if (Array.isArray(guard.tags) && guard.tags.length) {
292
+ const tags = ctx?.tags || []
293
+ // 画像未知(tags 为空)时不过滤:这是既有语义,保持兼容。
294
+ if (tags.length && !guard.tags.some((t) => tags.includes(String(t)))) return false
187
295
  }
188
- for (const sym of trigger.symbols || []) {
189
- const s = String(sym || '')
190
- if (s && haystack.includes(s)) return `symbol:${s}`
296
+ return true
297
+ }
298
+
299
+ /**
300
+ * trigger 命中判定(确定性,无打分)——**准入化后的语义**。
301
+ *
302
+ * `when` 是唯一的触发面,三个成员按 OR:`ops`(动作)/`writes`(本次要写的文件)/
303
+ * `intents`(人类消息里剥离引用后的意图词)。`guard` 只能收窄。
304
+ * **没有 `when` 的条目不再触发任何东西**(降级为可发现 + 按需拉取)。
305
+ *
306
+ * @param {object|null} trigger
307
+ * @param {object} ctx - {@link buildReadinessContext} 的结果
308
+ * @returns {string|null} 命中原因(`op:…` / `write:…` / `intent:…`),未命中为 null
309
+ */
310
+ export function matchTrigger(trigger, ctx) {
311
+ if (!trigger || typeof trigger !== 'object') return null
312
+ const when = trigger.when
313
+ if (!when || typeof when !== 'object') return null
314
+ if (!guardPass(trigger.guard, ctx)) return null
315
+ for (const op of when.ops || []) {
316
+ if (op && (ctx?.ops || []).includes(String(op))) return `op:${op}`
191
317
  }
192
- const actions = new Set(ctx?.actions || [])
193
- for (const a of trigger.actions || []) {
194
- const id = String(a || '')
195
- if (id && actions.has(id)) return `action:${id}`
318
+ // 写目标 = 已观察到的结构化写调用 ∪ 人类消息里提到的路径(动手前的写意图)。
319
+ // DSH 没有工具执行前拦截钩子,动手前唯一能拿到的写意图就是人类消息里的路径;
320
+ // 但动作文本里的**读**路径不算(`read README.md` 不是"即将写 README.md")。
321
+ const writeTargets = [...(ctx?.targets || []), ...(ctx?.humanPaths || [])]
322
+ for (const w of when.writes || []) {
323
+ // 扩展名/泛名 glob 在这里被硬性忽略:`*.pptx` 这类条件只能撒谎,不能收窄。
324
+ if (!w || isDroppableGlob(w)) continue
325
+ if (matchAnyPath(w, writeTargets)) return `write:${w}`
196
326
  }
197
- for (const p of trigger.paths || []) {
198
- const pattern = String(p || '')
199
- if (!pattern) continue
200
- const re = globToRegExp(pattern)
201
- const hit = (ctx?.paths || []).some((cand) => {
202
- const c = String(cand || '')
203
- if (!c) return false
204
- return re.test(c) || re.test(c.split(/[\\/]/).pop() || '')
205
- })
206
- if (hit) return `path:${pattern}`
327
+ const intent = ctx?.intent ?? ctx?.humanText ?? ''
328
+ for (const it of when.intents || []) {
329
+ if (it && matchIntent(it, intent)) return `intent:${it}`
207
330
  }
208
331
  return null
209
332
  }
210
333
 
334
+ /**
335
+ * 旧 trigger → 新 schema(纯函数、幂等、不改原对象)。
336
+ *
337
+ * 迁移规则(都在实测里有据):
338
+ * - `actions` → `when.ops`(经 {@link opForLegacyAction} 映射;死值记录进 `triggerNormalized.dropped`)
339
+ * - 具体的 `paths` → `when.writes`("我要改这个文件");扩展名/泛名 glob 直接丢弃
340
+ * - `keywords` → `when.intents`,只留通过 {@link isIntentWord} 的
341
+ * - `scope` → 默认忽略(它的值不在项目画像 tag 空间里,实测把最相关的一条 procedure 判了死刑)
342
+ *
343
+ * @param {object} it
344
+ * @param {{legacyScope?: 'ignore'|'filter'}} [opts]
345
+ */
346
+ export function normalizeTrigger(it, opts = {}) {
347
+ const t = it && it.trigger
348
+ if (!t || typeof t !== 'object') return it
349
+ if (t.when && typeof t.when === 'object') return it // 已是新 schema:幂等
350
+ const dropped = []
351
+ const writes = []
352
+ for (const p of t.paths || []) {
353
+ if (!p) continue
354
+ if (isDroppableGlob(p)) dropped.push(`path:${p}`)
355
+ else writes.push(String(p))
356
+ }
357
+ const ops = new Set()
358
+ for (const a of t.actions || []) {
359
+ const op = opForLegacyAction(a)
360
+ if (!op) {
361
+ dropped.push(`action:${a}`)
362
+ continue
363
+ }
364
+ // 有精确 writes 时丢掉弱 op:留着它只会把"改这个文件时"扩大成"任何一次提交时"。
365
+ if (writes.length && WEAK_OPS.has(op)) {
366
+ dropped.push(`weak-action:${a}`)
367
+ continue
368
+ }
369
+ ops.add(op)
370
+ }
371
+ // 具体路径只写进 `when.writes`,**不要**再补一个 `file-write` 到 ops:
372
+ // when 的成员是取或的,补进去等于让"写了任何文件"就命中,把路径条件短路掉
373
+ // (实测:那样会让 D 场景一次多出 6 条假阳性)。
374
+ const intents = []
375
+ for (const k of t.keywords || []) if (isIntentWord(k)) intents.push(String(k))
376
+ // 旧 `symbols` 也走文本平面:不归一就等于静默丢掉一个作者写下的触发面
377
+ // (README 承诺 keywords/symbols/actions/paths/scope 都会迁移)。与 keywords 同一把准入尺子。
378
+ for (const s of t.symbols || []) if (isIntentWord(s)) intents.push(String(s))
379
+ const when = {}
380
+ if (ops.size) when.ops = [...ops].sort()
381
+ if (writes.length) when.writes = [...new Set(writes)]
382
+ if (intents.length) when.intents = [...new Set(intents)]
383
+ const out = { ...t, when }
384
+ const scopeIgnored = Array.isArray(t.scope) && t.scope.length > 0
385
+ if (scopeIgnored && opts.legacyScope !== 'ignore') out.guard = { ...(t.guard || {}), tags: t.scope }
386
+ return { ...it, trigger: out, triggerNormalized: { dropped, scopeIgnored } }
387
+ }
388
+
389
+ /**
390
+ * trigger 自检(S5):把"哪些条目其实推不动、哪些声明是死的"变成可数的事实。
391
+ * @param {object[]} items
392
+ */
393
+ export function auditTriggers(items, opts = {}) {
394
+ const out = {
395
+ total: 0,
396
+ pushable: 0,
397
+ pullOnly: [],
398
+ deadActions: [],
399
+ droppedGlobs: [],
400
+ weakDropped: [],
401
+ fallbackOps: [],
402
+ scopeIgnored: [],
403
+ missingPrevents: [],
404
+ }
405
+ for (const raw of items || []) {
406
+ if (!raw || !raw.id) continue
407
+ out.total++
408
+ const it = normalizeTrigger(raw, opts)
409
+ const meta = it.triggerNormalized || {}
410
+ for (const d of meta.dropped || []) {
411
+ if (d.startsWith('action:')) out.deadActions.push({ id: it.id, value: d.slice(7) })
412
+ else if (d.startsWith('path:')) out.droppedGlobs.push({ id: it.id, value: d.slice(5) })
413
+ else if (d.startsWith('weak-action:')) out.weakDropped.push({ id: it.id, value: d.slice(12) })
414
+ }
415
+ if (meta.scopeIgnored) out.scopeIgnored.push(it.id)
416
+ const when = it.trigger?.when
417
+ for (const w of when?.writes || []) {
418
+ if (isDroppableGlob(w)) out.droppedGlobs.push({ id: it.id, value: w })
419
+ }
420
+ const hasWhen = Boolean(when && ((when.ops || []).length || (when.writes || []).length || (when.intents || []).length))
421
+ if (hasWhen) {
422
+ out.pushable++
423
+ // 兜底 op 出现在 when.ops 里:不是错误,但这条触发面比它看起来宽得多。
424
+ const fb = (when.ops || []).filter((o) => FALLBACK_OPS.has(o))
425
+ if (fb.length) out.fallbackOps.push({ id: it.id, ops: fb })
426
+ } else {
427
+ out.pullOnly.push(it.id)
428
+ }
429
+ if (!it.trigger?.prevents) out.missingPrevents.push(it.id)
430
+ }
431
+ return out
432
+ }
433
+
434
+ /**
435
+ * IDF 加权覆盖率(S4 的**绝对**门槛):条目覆盖了查询里多少**信息量**,而不是多少个 token。
436
+ *
437
+ * 为什么需要它:`relativeHits` 只看"层内最高分的比例",而最高分本身可能就是噪声——
438
+ * 实测 `relative:1.00` 出现在和查询毫无关系的条目上。归一在 [0,1]、有真零点,才能设下限。
439
+ *
440
+ * 返回四个量,调用方要一起看:
441
+ * - `coverage`:在**语料能表示**的词里,条目覆盖了多少信息量(分母不含 df=0 的词——
442
+ * 自然语言查询总有语料没有的词,把它们算成未命中会让任何正常查询都趋零)。
443
+ * - `matched`:命中的词数。单个通用词("插件")也能拿到 coverage=1.00。
444
+ * - `supported` / `terms`:语料里有对应词的比例。长句子里只有一两个词能在语料中找到对应时,
445
+ * "覆盖率 1.00"是假象(实测:一句 19 个词的改时间戳请求,只与 WSL 笔记共享一个"文件",
446
+ * 却拿到 cov=1.00),调用方据此让**整条通道沉默**。
447
+ * @returns {{coverage: number, matched: number, supported: number, terms: number}}
448
+ */
449
+ export function idfCoverage(query, itemText, corpusTexts) {
450
+ const q = [...new Set(tokenize(query))]
451
+ if (!q.length) return { coverage: 0, matched: 0, supported: 0, terms: 0 }
452
+ const item = new Set(tokenize(itemText))
453
+ const corpus = (corpusTexts || []).map((t) => new Set(tokenize(t)))
454
+ const n = Math.max(corpus.length, 1)
455
+ let total = 0
456
+ let hit = 0
457
+ let matched = 0
458
+ let supported = 0
459
+ for (const term of q) {
460
+ let df = 0
461
+ for (const doc of corpus) if (doc.has(term)) df++
462
+ // df=0 的词不计入分母:它对"选哪一条"没有分辨力。但 supported 会记下有多少词是
463
+ // 语料根本无法表示的——那是"这条查询整体上离语料太远"的证据,交给调用方决定沉默。
464
+ if (df === 0) continue
465
+ supported++
466
+ const w = Math.log(1 + n / (1 + df))
467
+ total += w
468
+ if (item.has(term)) {
469
+ hit += w
470
+ matched++
471
+ }
472
+ }
473
+ return { coverage: total > 0 ? hit / total : 0, matched, supported, terms: q.length }
474
+ }
475
+
211
476
  /**
212
477
  * 层内相对阈值:命中分至少达到该层最高分的 `ratioMin`。尺度无关(BM25 分数无上界),
213
478
  * 取代"整条消息 vs 整条 insight 的集合重叠 ÷ 较大集合"这种长度敏感判据。
@@ -219,6 +484,9 @@ export function relativeHits(scored, { ratioMin = 0.5 } = {}) {
219
484
  const list = (scored || []).filter((r) => r && Number.isFinite(r.score) && r.score > 0)
220
485
  if (!list.length) return []
221
486
  const top = list[0].score
222
- const floor = top * (typeof ratioMin === 'number' && ratioMin > 0 ? ratioMin : 0.5)
487
+ // 显式的 0 表示"关掉相对门槛"(只剩 score>0);未给/非法值才回退 0.5。
488
+ // 旧写法 `ratioMin > 0 ? ratioMin : 0.5` 把 0 当成"没配",配置上无法关闭。
489
+ const ratio = typeof ratioMin === 'number' && Number.isFinite(ratioMin) ? ratioMin : 0.5
490
+ const floor = top * Math.max(0, Math.min(1, ratio))
223
491
  return list.filter((r) => r.score >= floor)
224
492
  }
package/src/recall.js CHANGED
@@ -101,6 +101,21 @@ export function insightToEntry(ins) {
101
101
  }
102
102
  }
103
103
 
104
+ /**
105
+ * 一条 insight 的**打分文本**(BM25 与 IDF 覆盖率必须用同一份)。
106
+ *
107
+ * 为什么要有这个函数:提示通道的"这条查询离语料有多远"(`idfCoverage` 的 df)曾经用
108
+ * `insightMatchText` 计算,而排序用 `insightToEntry`(含 fix/solution/reason/steps)。
109
+ * 于是"只在解药里有匹配"的查询词 df=0 → supportRatio=0 → **整条提示通道本轮沉默**,
110
+ * 而排序明明给了它高分。两份文本永远来自同一个 entry,才不会再漂移。
111
+ * @param {object} ins
112
+ * @returns {string}
113
+ */
114
+ export function insightScoringText(ins) {
115
+ const e = insightToEntry(ins)
116
+ return [e.title, e.summary, e.terms, ...(e.keywords || [])].filter(Boolean).join('\n')
117
+ }
118
+
104
119
  /**
105
120
  * 召回可见的 insight 集合:作用域可见性跟随会话绑定。
106
121
  * - project / global:始终可见(归档除外);
@@ -127,6 +142,9 @@ export function visibleInsights({ store = null, globalStore = null, boundTaskId
127
142
  const task = store.getTask(boundTaskId)
128
143
  for (const it of (task && task.insights) || []) {
129
144
  if (!it || it.archived) continue
145
+ // 任务级也要挡草稿:reflection 写的是 draft:true,未确认前不该被 query_memory 召回
146
+ // (project/global 一直有这道闸,任务级漏了)。
147
+ if (it.draft === true) continue
130
148
  out.push({ ...it, scope: it.scope || 'task' })
131
149
  }
132
150
  }
@@ -163,15 +181,17 @@ export function recallItems(opts = {}) {
163
181
  if (want.has('doc') || want.has('symbol')) {
164
182
  const pool = (entries || (store && typeof store.allEntries === 'function' ? store.allEntries() : []) || [])
165
183
  .filter((e) => e && (e.type === 'doc' || e.type === 'symbol') && want.has(e.type))
166
- const scored = rankEntriesStreaming(pool, qs, idf, limit)
167
- const byLayer = new Map([['doc', []], ['symbol', []]])
168
- for (const { entry, score } of scored) {
169
- const prior = entry.type === 'doc' ? normativePrior(entry) : LAYER_PRIORS.symbol
170
- byLayer.get(entry.type).push({ item: entry, score, prior, weightedScore: score * prior })
171
- }
184
+ // 每层各自 top-k(不变量 #2):一次跨类型取 top-k 会让文档把符号挤出结果——
185
+ // 20 条命中文档 + 1 条命中符号、limit 8 时符号层直接消失。分类型各取一次再分桶。
172
186
  for (const layer of ['doc', 'symbol']) {
173
- const hits = byLayer.get(layer)
174
- if (!hits.length) continue
187
+ const layerPool = pool.filter((e) => e.type === layer)
188
+ if (!layerPool.length) continue
189
+ const scored = rankEntriesStreaming(layerPool, qs, idf, limit)
190
+ if (!scored.length) continue
191
+ const hits = scored.map(({ entry, score }) => {
192
+ const prior = layer === 'doc' ? normativePrior(entry) : LAYER_PRIORS.symbol
193
+ return { item: entry, score, prior, weightedScore: score * prior }
194
+ })
175
195
  hits.sort((a, b) => b.weightedScore - a.weightedScore)
176
196
  const top = hits[0].weightedScore || 1
177
197
  buckets.push({
@@ -2,7 +2,7 @@
2
2
  // 事实依据:dsh session/event 签名 (session, event);todo/write data.todos;
3
3
  // tool/call data.arguments 为 JSON 字符串;fs 工具名 read/write/edit/read_image,参数 file_path。
4
4
  import path from 'node:path'
5
- import { createHash } from 'node:crypto'
5
+ import { createHash, randomUUID } from 'node:crypto'
6
6
  import { memoryRootFor } from '../util/fs.js'
7
7
  import { findProjectRoot } from '../lazy.js'
8
8
  import { ProjectMemoryStore } from '../store.js'
@@ -27,7 +27,9 @@ export function slugifyTitle(text) {
27
27
 
28
28
  export function genTaskId(projectRoot, title) {
29
29
  const slug = slugifyTitle(title) || 'task'
30
- return `tsk_${hash8(projectRoot)}_${slug}_${Date.now()}`
30
+ // 毫秒不是唯一性保证:两个会话在同一毫秒建同名任务会拿到同一个 id,
31
+ // addTask 变成"同一 id 两次",两个会话的绑定互相串台。补 6 位随机后缀。
32
+ return `tsk_${hash8(projectRoot)}_${slug}_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`
31
33
  }
32
34
 
33
35
  /** 项目根推导:findProjectRoot 期望文件路径,传目录会从父级起跳,故用目录内探针路径。 */
@@ -138,13 +140,7 @@ export function hotSortFiles(files, meta) {
138
140
  /** 触碰文件后更新元数据并重排 task.files(写 vs 读分别记时间/次数)。 */
139
141
  export function touchTaskFile(task, rel, kind, now) {
140
142
  task.fileMeta = task.fileMeta || {}
141
- if (!task.files.includes(rel)) {
142
- task.files.push(rel)
143
- if (task.files.length > MAX_FILES_PER_TASK) {
144
- const dropped = task.files.shift()
145
- delete task.fileMeta[dropped]
146
- }
147
- }
143
+ if (!task.files.includes(rel)) task.files.push(rel)
148
144
  const m = task.fileMeta[rel] || {}
149
145
  m.n = (m.n || 0) + 1 // 兼容旧字段:读+写总数(保留,避免老任务语义漂移)
150
146
  // 读/写分计数(先只采集,不消费 —— 排序与注入仍只用 lastWriteAt/lastReadAt)
@@ -157,6 +153,12 @@ export function touchTaskFile(task, rel, kind, now) {
157
153
  m.lastReadAt = now
158
154
  task.fileMeta[rel] = m
159
155
  task.files = hotSortFiles(task.files, task.fileMeta)
156
+ // 超限丢最冷的:列表已按热度降序,末尾即最冷。旧代码在**重排前** shift(),
157
+ // 而那时 files 本身就是热序,shift 恰好把最热的那个文件(刚写的)丢掉、留下最冷的。
158
+ while (task.files.length > MAX_FILES_PER_TASK) {
159
+ const dropped = task.files.pop()
160
+ delete task.fileMeta[dropped]
161
+ }
160
162
  }
161
163
 
162
164
  /**
package/src/store.js CHANGED
@@ -17,6 +17,14 @@ const SHARDS_DIR = 'shards'
17
17
  const storeCache = new Map()
18
18
  const STORE_CACHE_MAX = 32
19
19
 
20
+ /** 已就"无法迁移的旧 store"告警过的目录:避免每次 load() 都刷一行。 */
21
+ const migrationWarned = new Set()
22
+
23
+ /** 纯对象判定(排除 null / 数组):磁盘读入的 JSON 形状校验统一走它。 */
24
+ function isRecord(value) {
25
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
26
+ }
27
+
20
28
  function loadJson(filePath, fallback) {
21
29
  let raw
22
30
  try {
@@ -82,7 +90,9 @@ export class ProjectMemoryStore {
82
90
  load() {
83
91
  const key = path.resolve(this.dir)
84
92
  const hot = storeCache.get(key)
85
- if (hot && hot !== this) return hot
93
+ // 缓存命中即返回:`hot === this` 时再读一遍盘会静默丢弃本实例尚未 save() 的变更
94
+ // (_loadSharded/_loadInsights 会重新赋值 experience/tasks/insights…)。
95
+ if (hot) return hot
86
96
  this._migrateLegacyIfNeeded()
87
97
  this._loadSharded()
88
98
  this._loadInsights()
@@ -114,9 +124,34 @@ export class ProjectMemoryStore {
114
124
  }
115
125
  const legacyEntriesPath = path.join(this.dir, ENTRIES_FILE)
116
126
  if (!existsSafe(legacyEntriesPath)) return
117
- const index = loadJson(path.join(this.dir, INDEX_FILE), {})
118
- const files = index.files || {}
119
- const entries = loadJson(legacyEntriesPath, {})
127
+ const legacyIndexPath = path.join(this.dir, INDEX_FILE)
128
+ // 先读两份旧文件:损坏的那份会在这里被 loadJson 备份为 .corrupt(保留现场)。
129
+ const index = loadJson(legacyIndexPath, null)
130
+ const entries = loadJson(legacyEntriesPath, null)
131
+ if (!isRecord(index) || !isRecord(entries)) {
132
+ const hasEntries = isRecord(entries) && Object.keys(entries).length > 0
133
+ if (!hasEntries) {
134
+ // entries 损坏(已备份)或本就是空的:没有可保护的数据,按空旧库收尾。
135
+ writeJsonAtomic(formatPath, { version: 2, layout: 'sharded' })
136
+ for (const stale of [legacyEntriesPath, legacyIndexPath]) {
137
+ try {
138
+ unlinkSync(stale)
139
+ } catch {
140
+ // already renamed away by corrupt backup, or gone; nothing to do
141
+ }
142
+ }
143
+ return
144
+ }
145
+ // index.json 是 entries.json → rel 的唯一映射。它缺失或损坏时继续迁移,会写出 0 个
146
+ // shard、打上 v2 标记、再把**完好的** entries.json 删掉——等于一次静默的数据清空。
147
+ // 保留现场,不写 format 标记,等 index.json 修好后再迁(每次进程只提示一次)。
148
+ if (!migrationWarned.has(this.dir)) {
149
+ migrationWarned.add(this.dir)
150
+ console.error(`[dsh-project-memory] legacy store at ${this.dir} has ${ENTRIES_FILE} but no readable ${INDEX_FILE}; migration skipped to protect it`)
151
+ }
152
+ return
153
+ }
154
+ const files = isRecord(index.files) ? index.files : {}
120
155
  const orphans = Object.keys(entries).filter((rel) => !(rel in files))
121
156
  if (orphans.length) {
122
157
  console.error(
@@ -147,9 +182,11 @@ export class ProjectMemoryStore {
147
182
  }
148
183
  for (const name of shardNames) {
149
184
  const shard = loadJson(path.join(this.dir, SHARDS_DIR, name), null)
150
- if (!shard || typeof shard.relPath !== 'string' || !shard.record) continue
185
+ if (!shard || typeof shard.relPath !== 'string' || !isRecord(shard.record)) continue
151
186
  this.files[shard.relPath] = shard.record
152
- this.entries[shard.relPath] = shard.entries || []
187
+ // 畸形 shard(entries 被写成对象/null)不能让 allEntries() 在 `for…of` 上抛错,
188
+ // 否则一个坏文件会拖垮整个进程的每一次读取。
189
+ this.entries[shard.relPath] = Array.isArray(shard.entries) ? shard.entries.filter(isRecord) : []
153
190
  }
154
191
  this.experience = loadJson(path.join(this.dir, EXPERIENCE_FILE), [])
155
192
  this.tasks = loadJson(path.join(this.dir, TASKS_FILE), [])
@@ -165,7 +202,10 @@ export class ProjectMemoryStore {
165
202
  // migratedAt 落盘保证跨进程/崩溃幂等。销毁式收敛放到 recall 统一 PR。
166
203
  _loadInsights() {
167
204
  const doc = loadJson(path.join(this.dir, INSIGHTS_FILE), null)
168
- this.insights = doc && typeof doc === 'object' && Array.isArray(doc.items) ? doc : { version: 1, migratedAt: null, items: [] }
205
+ this.insights = isRecord(doc) && Array.isArray(doc.items)
206
+ // items 里混进 null/非对象(手改或旧版写入)会让迁移与召回逐个 `.title` 抛错——过滤掉。
207
+ ? { ...doc, items: doc.items.filter(isRecord) }
208
+ : { version: 1, migratedAt: null, items: [] }
169
209
  this._migrateExperienceToInsights()
170
210
  // PR3:v1 → v2 懒回填派生 trigger(纯确定性、幂等;不调用模型,不改写已有字段)
171
211
  if (backfillDerivedTriggers(this.insights)) this._dirtyInsights = true
@@ -551,15 +591,14 @@ export class ProjectMemoryStore {
551
591
  return result
552
592
  }
553
593
 
554
- applyFileUpdate(relPath, { expectedHash, hash, entries, meta, deleted, type, size }) {
555
- const cur = this.fileRecord(relPath)
556
- if (!deleted && (cur?.sha256 ?? null) !== (expectedHash ?? null)) {
557
- return { skipped: true }
558
- }
559
- if (deleted) {
560
- this.removeFile(relPath)
561
- return { ok: true }
562
- }
594
+ /**
595
+ * 写入一条文件更新,以 expectedHash 做 CAS。
596
+ * @returns {boolean} true = 已写入;false = 文件自扫描后又被改动,本次拒绝
597
+ * (调用方让该条目保持未落快照,下一轮重试)。
598
+ * 移除条目不经过这里:调用方在 commit 里直接 removeFile(见 index-pipeline.js)。
599
+ */
600
+ applyFileUpdate(relPath, { expectedHash, hash, entries, type, size }) {
601
+ if ((this.fileRecord(relPath)?.sha256 ?? null) !== (expectedHash ?? null)) return false
563
602
  this.markFile(relPath, {
564
603
  sha256: hash,
565
604
  size,
@@ -567,7 +606,7 @@ export class ProjectMemoryStore {
567
606
  indexedAt: new Date().toISOString(),
568
607
  })
569
608
  this.setEntries(relPath, entries)
570
- return { ok: true }
609
+ return true
571
610
  }
572
611
  }
573
612