@yolk_vat-y/dsh-project-memory 0.5.4 → 0.5.6

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.
@@ -13,9 +13,20 @@ import { insightMatchText } from './similarity.js'
13
13
  import { normalizedTokenOverlap } from './similarity.js'
14
14
  import { cfgInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
15
15
  import { projectTags } from './project-profile.js'
16
+ import { rankEntriesMergedScored } from './util/search.js'
17
+ import { insightToEntry } from './recall.js'
18
+ import { buildReadinessContext, hintQueryText, idfCoverage, matchTrigger, normalizeTrigger, relativeHits } from './readiness.js'
19
+ import { activityFromCalls } from './ops.js'
20
+ import { appendInjectionAudit, auditRecordFrom, cfgAudit } from './audit.js'
16
21
 
17
22
  export const INJECT_MARK = '[Memory Inject]'
18
23
 
24
+ /**
25
+ * 注入正文的最小可用长度:预算塞不下这么多就宁可不注入(记 dropped)。
26
+ * 与其输出 `- [ins_xxx] procedure ` 这种 stub,不如保持沉默——stub 只消耗 token 不传递信息。
27
+ */
28
+ const MIN_BODY_CHARS = { trigger: 48, hint: 120 }
29
+
19
30
  /** 注入引擎配置:insight 默认之上叠加 autoContext 预算/门控参数。 */
20
31
  export function cfgEngine(config) {
21
32
  const c = (config && config.autoContext) || {}
@@ -24,11 +35,44 @@ export function cfgEngine(config) {
24
35
  ...base,
25
36
  maxTokens: typeof c.maxTokens === 'number' ? c.maxTokens : 400,
26
37
  entryMaxInsights: typeof c.entryMaxInsights === 'number' ? c.entryMaxInsights : 6,
27
- relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : 0.25,
38
+ // 提示通道的相对阈值(层内最高分的比例):尺度无关,取代旧的"绝对 overlap"常量。
39
+ // 旧判据 normalizedTokenOverlap = |交集| / max(|A|,|B|) 对长消息必然趋零(实测 0.014–0.057)。
40
+ signalMinRatio: typeof c.signalMinRatio === 'number' ? c.signalMinRatio : 0.5,
41
+ // 兼容闸门:显式配置 relevanceMin 时仍走旧的绝对 overlap 判据;不配置则为 null(走相对阈值)。
42
+ relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : null,
28
43
  // resident 任务卡最多显示几个"编辑中"文件(纯写权重,最近写优先)
29
44
  editedMax: typeof c.editedMax === 'number' ? c.editedMax : 3,
30
45
  // 模型自己写/维护任务清单后、尚无新人类消息时,不把任务卡再回声给模型(省 token)
31
46
  skipEchoSelfTodo: c.skipEchoSelfTodo !== false,
47
+ // 预算审计日志级别(stderr)。默认 off:预算挤掉低优先级条目是正常降级,不是故障,
48
+ // 而终端是用户可见面——默认打印会让一次启动刷出多行,代价远大于那点可观测性收益。
49
+ // off : 永不打印(默认)
50
+ // once : 每个会话最多一行(首次出现预算丢弃时),够定位又不刷屏
51
+ // all : 丢弃组合每变化一次打一行(作者排查用)
52
+ budgetLog: ['off', 'once', 'all'].includes(c.budgetLog) ? c.budgetLog : 'off',
53
+ // 同一条 insight 在本会话里重复注入的冷却(单位:pre-step 步数)。
54
+ // 0(默认)= 内容没变就不再注入:注入消息会留在会话历史里(宿主不压缩历史),
55
+ // 整块重发同一条 1200 字 procedure 只是重复占位。>0 用于历史可能被外部裁剪的场景。
56
+ reinjectItemsAfter: typeof c.reinjectItemsAfter === 'number' && c.reinjectItemsAfter >= 0 ? c.reinjectItemsAfter : 0,
57
+ // ---- S2/S3/S4 新增(准入化改造) ----
58
+ // 旧 `scope` 的处理:'filter'(默认,保留旧语义——不静默改用户数据)或 'ignore'。
59
+ // 注意:实测里唯一带 scope 的条目,其值不在项目画像 tag 空间内,等于被判死刑;
60
+ // 自检会把它报出来,由作者决定改值还是改语义。
61
+ legacyScope: c.legacyScope === 'ignore' ? 'ignore' : 'filter',
62
+ // 条目通道的步间隔:两次"条目注入"之间至少隔这么多步(常驻任务卡不受限——它是状态快照,
63
+ // 内容变了就该更新)。这是"不频繁"的主要旋钮。
64
+ gateCooldownSteps: typeof c.gateCooldownSteps === 'number' && c.gateCooldownSteps >= 0 ? c.gateCooldownSteps : 2,
65
+ // 每会话条目注入的上限(条数 / 字符):预算只能是上限,不是目标。
66
+ maxItemsPerSession: typeof c.maxItemsPerSession === 'number' && c.maxItemsPerSession >= 0 ? c.maxItemsPerSession : 12,
67
+ maxItemCharsPerSession: typeof c.maxItemCharsPerSession === 'number' && c.maxItemCharsPerSession >= 0 ? c.maxItemCharsPerSession : 4000,
68
+ // 提示通道的**绝对**下限(IDF 加权覆盖率):相对阈值分不出"有信号"和"矮子里拔将军"。
69
+ // null = 关闭(回到只有相对阈值的老行为)。
70
+ hintMinCoverage: typeof c.hintMinCoverage === 'number' && c.hintMinCoverage >= 0 ? c.hintMinCoverage : 0.3,
71
+ // 提示通道还要求至少这么多个共同词:单个通用词("插件")也能拿到 coverage=1.00。
72
+ hintMinMatched: typeof c.hintMinMatched === 'number' && c.hintMinMatched >= 0 ? c.hintMinMatched : 2,
73
+ // 通道级沉默:查询里能在语料中找到对应的词占比低于这个值时,整条提示通道本轮不出声。
74
+ // 长句子里只有一两个词碰巧命中,coverage 会虚高到 1.00——这条门就是为它设的。
75
+ hintMinSupport: typeof c.hintMinSupport === 'number' && c.hintMinSupport >= 0 ? c.hintMinSupport : 0.15,
32
76
  }
33
77
  }
34
78
 
@@ -40,16 +84,20 @@ function textOf(message) {
40
84
  .join('\n')
41
85
  }
42
86
 
43
- function lastUserText(messages) {
87
+ export function lastUserText(messages) {
44
88
  if (!Array.isArray(messages)) return ''
89
+ let fallback = ''
45
90
  for (let i = messages.length - 1; i >= 0; i--) {
46
91
  const m = messages[i]
47
- if (m && m.role === 'user') {
48
- const t = textOf(m)
49
- if (t.trim()) return t.trim()
50
- }
92
+ if (!m || m.role !== 'user') continue
93
+ const t = textOf(m)
94
+ if (!t.trim()) continue
95
+ // 真人消息优先:本插件注入的块也是 role=user,若按"最后一条 user"取,
96
+ // 上一步的注入块会变成这一步的就绪查询(自激:拿自己注入的内容再检索一遍)。
97
+ if (m.source && m.source.kind === 'user') return t.trim()
98
+ if (!fallback) fallback = t.trim() // 无 source 的消息(老宿主 / 测试)兜底
51
99
  }
52
- return ''
100
+ return fallback
53
101
  }
54
102
 
55
103
  function fingerprint(text) {
@@ -103,65 +151,188 @@ export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
103
151
  return parts.join('\n')
104
152
  }
105
153
 
154
+ /** 候选池:project + global + 绑定任务(同一 id 只取一次;归档/草稿不进注入)。
155
+ * 候选在进入匹配前统一走旧 trigger → 新 schema 的归一(幂等,纯内存,不落盘)。 */
156
+ function insightCandidates(store, globalStore, task, opts) {
157
+ const out = []
158
+ const seen = new Set()
159
+ const push = (it) => {
160
+ if (!it || it.archived || it.draft) return
161
+ const key = it.id || `${it.kind}:${it.title}`
162
+ if (seen.has(key)) return
163
+ seen.add(key)
164
+ out.push(normalizeTrigger(it, { legacyScope: opts?.legacyScope }))
165
+ }
166
+ for (const it of (store ? store.insightItems() : []) || []) push(it)
167
+ for (const it of (globalStore ? globalStore.items() : []) || []) push(it)
168
+ for (const it of (task && task.insights) || []) push(it)
169
+ return out
170
+ }
171
+
172
+ /** 一条 insight 的注入正文:procedure 给全步骤,其余给标题 + 结论。 */
173
+ function insightBody(it) {
174
+ return it.kind === 'procedure' && Array.isArray(it.steps)
175
+ ? `【${it.title}】\n${it.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
176
+ : `- [${it.id}] ${it.title}${it.fix || it.solution || it.choice ? ` — ${it.fix || it.solution || it.choice}` : ''}`
177
+ }
178
+
179
+ /** 条目级去重键:注入正文的指纹(正文变了才允许再注入一次,见 cfg.reinjectItemsAfter)。 */
180
+ export function insightItemHash(it) {
181
+ return createHash('sha256').update(insightBody(it)).digest('hex').slice(0, 12)
182
+ }
183
+
184
+ /** 把正文塞进剩余预算:放不下就截断;连"有用前缀"都留不下就返回 null(由调用方记 dropped)。 */
185
+ export function fitBody(body, remaining, min = MIN_BODY_CHARS.hint) {
186
+ if (remaining <= min) return null
187
+ if (body.length <= remaining) return body
188
+ return `${body.slice(0, remaining - 1)}…`
189
+ }
190
+
106
191
  /**
107
192
  * 构建注入内容(不写盘、无副作用)。
108
- * @param {object} opts { query, task, store, globalStore, projectTagsList, cfg }
109
- * @returns {{ text: string, labels: string[] }}
193
+ *
194
+ * 交付契约:**authored trigger(所有 kind)= 确定性全文注入,优先级 1;统计信号 = 截断提示,
195
+ * 优先级 2**。预算排程为:常驻任务卡 → trigger 命中 → 提示。每一步都记 `reasons`(为什么注入)
196
+ * 与 `dropped`(为什么没注入),让"注入/未注入"可审计——而不是只有静默的门槛。
197
+ * @param {object} opts { query, readiness, task, store, globalStore, projectTagsList, cfg, skipItem? }
198
+ * @param {(it: object) => boolean} [opts.skipItem] 条目级去重:返回 true 的条目直接退出排程
199
+ * (本会话已注入过且正文未变)。在排程前过滤,省下的预算留给新条目。
200
+ * @returns {{ text: string, labels: string[], reasons: object[], dropped: object[] }}
110
201
  */
111
202
  export function buildInjection(opts) {
112
203
  const { query, task, globalStore, cfg } = opts
113
204
  const store = opts.store || null
114
205
  const tags = opts.projectTagsList || []
206
+ const ctx = buildReadinessContext({ ...(opts.readiness || { humanText: query || '' }), tags })
115
207
  const labels = []
208
+ const reasons = []
209
+ const dropped = []
116
210
  const parts = []
117
- let budgetChars = (cfg.maxTokens || 400) * 3
118
-
119
- // B) relevance:procedure(trigger 命中)全步骤优先
120
- const gItems = globalStore ? globalStore.items() : []
121
- const matched = []
122
- for (const it of gItems) {
123
- if (!it || it.archived || it.kind !== 'procedure' || !it.trigger) continue
124
- const tr = it.trigger
125
- const kwHit = (tr.keywords || []).some((k) => query.toLowerCase().includes(String(k).toLowerCase()))
126
- const symHit = (tr.symbols || []).some((s) => query.includes(s))
127
- if (!kwHit && !symHit) continue
128
- if (Array.isArray(tr.scope) && tr.scope.length && tags.length) {
129
- if (!tr.scope.some((s) => tags.includes(s))) continue // 画像过滤
130
- }
131
- matched.push({ it, score: 1 })
132
- }
133
- // procedure 之外的相关 insights(project + global 非草稿非归档),lexical top-k
134
- const cands = [...(store ? store.insightItems() : []), ...gItems]
211
+ // 预算只能是上限,不是目标:单轮额度(maxTokens)与会话级剩余额度取小者。
212
+ const quotaChars = typeof opts.maxChars === 'number' ? opts.maxChars : Infinity
213
+ const maxItems = typeof opts.maxItems === 'number' ? opts.maxItems : Infinity
214
+ const skipItems = opts.skipItems === true
215
+ const budgetChars = Math.max(0, Math.min((cfg.maxTokens || 400) * 3, quotaChars))
216
+
217
+ const skipItem = typeof opts.skipItem === 'function' ? opts.skipItem : null
218
+ const cands = insightCandidates(store, globalStore, task, cfg).filter((it) => !(skipItem && skipItem(it)))
219
+
220
+ // 通道 1:authored trigger —— 确定性、全文、优先级 1。procedure 先于其它 kind(步骤更完整)。
221
+ // 准入化(S2):只有 `when`(op / 写目标 / 意图词)能触发,guard 只能收窄;
222
+ // **没有 `when` 的条目在这里永远不命中**(降级为可发现 + 按需拉取)。
223
+ const triggered = []
135
224
  for (const it of cands) {
136
- if (!it || it.archived || it.draft) continue
137
- if (it.kind === 'procedure') continue // procedure 只经 trigger 通道注入,避免绕过 scope 过滤
138
- const score = normalizedTokenOverlap(query, insightMatchText(it))
139
- if (score >= (cfg.relevanceMin || 0.25)) matched.push({ it, score })
225
+ const why = matchTrigger(it.trigger, ctx)
226
+ if (!why) continue
227
+ triggered.push({ it, why })
140
228
  }
141
- matched.sort((a, b) => b.score - a.score)
142
- const seen = new Set()
143
- for (const { it } of matched) {
144
- if (seen.has(it.id)) continue
145
- seen.add(it.id)
146
- const label = it.kind === 'procedure' ? 'procedure' : 'insight'
147
- const body =
148
- it.kind === 'procedure' && Array.isArray(it.steps)
149
- ? `【${it.title}】\n${it.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
150
- : `- [${it.id}] ${it.title}${it.fix || it.solution || it.choice ? ` — ${it.fix || it.solution || it.choice}` : ''}`
151
- parts.push(body)
152
- labels.push(label)
153
- if (parts.join('\n').length > budgetChars) break
229
+ triggered.sort(
230
+ (a, b) => (a.it.kind === 'procedure' ? 0 : 1) - (b.it.kind === 'procedure' ? 0 : 1)
231
+ || String(a.it.id).localeCompare(String(b.it.id)),
232
+ )
233
+ if (skipItems) {
234
+ // 会话级限流命中:条目通道本轮整体沉默,但把"本该注入什么"记进 dropped——不做静默降级。
235
+ for (const t of triggered) dropped.push({ id: t.it.id, channel: 'trigger', reason: opts.silenceReason || 'cooldown' })
154
236
  }
237
+
238
+ // 通道 2:统计信号 —— 提示态、优先级 2。**双门槛**:层内相对阈值 + IDF 加权覆盖率(绝对下限)。
239
+ // 查询只用「人类消息的意图文字 + 本次写目标」:原始工具参数不再进查询,它们正是
240
+ // `dcterms→rm`、`*.pptx→论文笔记` 那类假阳性的来源。procedure 不进本通道(要过 trigger.scope)。
241
+ const consumed = new Set(triggered.map((t) => t.it.id))
242
+ const hintCands = skipItems ? [] : cands.filter((it) => !consumed.has(it.id) && it.kind !== 'procedure')
243
+ const hintQuery = [ctx.intent || ctx.humanText || '', ...(ctx.targets || [])].filter(Boolean).join(' ')
244
+ const hints = []
245
+ if (hintCands.length) {
246
+ if (typeof cfg.relevanceMin === 'number') {
247
+ // 兼容:显式 relevanceMin → 旧的绝对 overlap 判据(老 profile 行为不变)
248
+ for (const it of hintCands) {
249
+ const score = normalizedTokenOverlap(ctx.humanText || query || '', insightMatchText(it))
250
+ if (score >= cfg.relevanceMin) hints.push({ it, score, why: `overlap:${score.toFixed(3)}` })
251
+ }
252
+ hints.sort((a, b) => b.score - a.score)
253
+ } else if (hintQueryText(hintQuery)) {
254
+ const byId = new Map(hintCands.map((it) => [it.id, it]))
255
+ const corpus = hintCands.map((it) => insightMatchText(it))
256
+ // 查询与被测覆盖率的文本用**同一个**过滤后的查询:否则缩写(wsl/npm)会在 BM25 里被剔除、
257
+ // 却仍在覆盖率里计分,"至少两个共同词"就被它们凑够了。
258
+ const q = hintQueryText(hintQuery)
259
+ const scored = rankEntriesMergedScored(hintCands.map(insightToEntry), [q], hintCands.length)
260
+ const coverage = new Map(hintCands.map((it, i) => [it.id, idfCoverage(q, corpus[i], corpus)]))
261
+ const qStats = coverage.get(hintCands[0].id) || { supported: 0, terms: 0 }
262
+ const supportRatio = qStats.terms > 0 ? qStats.supported / qStats.terms : 0
263
+ // 通道级沉默:查询里绝大多数词在语料里根本没有对应 → "覆盖率 1.00"只是假象。
264
+ const channelThin = typeof cfg.hintMinSupport === 'number' && supportRatio < cfg.hintMinSupport
265
+ for (const r of relativeHits(scored, { ratioMin: cfg.signalMinRatio })) {
266
+ const it = byId.get(r.entry.insightId)
267
+ if (!it) continue
268
+ const ev = coverage.get(it.id) || { coverage: 0, matched: 0, supported: 0, terms: 0 }
269
+ if (channelThin) {
270
+ dropped.push({ id: it.id, channel: 'hint', reason: `support:${supportRatio.toFixed(2)}` })
271
+ continue
272
+ }
273
+ // 绝对门槛:覆盖率下限 + 共同词数下限(查询本身很短时下限按可用词数收敛)。
274
+ if (typeof cfg.hintMinCoverage === 'number' && ev.coverage < cfg.hintMinCoverage) {
275
+ dropped.push({ id: it.id, channel: 'hint', reason: `coverage:${ev.coverage.toFixed(2)}` })
276
+ continue
277
+ }
278
+ const needMatched = Math.min(typeof cfg.hintMinMatched === 'number' ? cfg.hintMinMatched : 2, ev.supported)
279
+ if (ev.matched < needMatched) {
280
+ dropped.push({ id: it.id, channel: 'hint', reason: `thin:${ev.matched}` })
281
+ continue
282
+ }
283
+ hints.push({ it, score: r.score, why: `relative:${(r.score / (scored[0].score || 1)).toFixed(2)} cov:${ev.coverage.toFixed(2)}/${ev.matched}` })
284
+ }
285
+ }
286
+ }
287
+
155
288
  // 常驻块文本。去重指纹只看稳定内容(任务标题/步骤/insights + 相关 insights):
156
289
  // “编辑中”随每次写文件变化,若参与指纹会导致每写一个文件就重发整块(噪音 + token 浪费)。
157
290
  const echo = shouldEchoTaskCard(task, cfg)
158
291
  const entry = echo ? buildEntryContent(task, cfg) : ''
159
292
  const entryStable = echo ? buildEntryContent(task, cfg, { withEdited: false }) : ''
293
+ let used = entry.length
294
+ let itemCount = 0
295
+
296
+ for (const { it, why } of triggered) {
297
+ if (skipItems) break
298
+ if (itemCount >= maxItems) {
299
+ dropped.push({ id: it.id, channel: 'trigger', reason: 'quota' })
300
+ continue
301
+ }
302
+ const body = fitBody(insightBody(it), budgetChars - used, MIN_BODY_CHARS.trigger)
303
+ if (body === null) {
304
+ dropped.push({ id: it.id, channel: 'trigger', reason: 'budget' })
305
+ continue
306
+ }
307
+ used += body.length + 1
308
+ itemCount++
309
+ parts.push(body)
310
+ labels.push(it.kind === 'procedure' ? 'procedure' : it.kind)
311
+ reasons.push({ id: it.id, channel: 'trigger', why, hash: insightItemHash(it), chars: body.length })
312
+ }
313
+ for (const { it, why } of hints) {
314
+ if (skipItems) break
315
+ if (itemCount >= maxItems) {
316
+ dropped.push({ id: it.id, channel: 'hint', reason: 'quota' })
317
+ continue
318
+ }
319
+ const body = fitBody(insightBody(it), budgetChars - used, MIN_BODY_CHARS.hint)
320
+ if (body === null) {
321
+ dropped.push({ id: it.id, channel: 'hint', reason: 'budget' })
322
+ continue
323
+ }
324
+ used += body.length + 1
325
+ itemCount++
326
+ parts.push(body)
327
+ labels.push('hint')
328
+ reasons.push({ id: it.id, channel: 'hint', why, hash: insightItemHash(it), chars: body.length })
329
+ }
330
+
160
331
  const total = [entry, ...parts].filter(Boolean)
161
- if (!total.length) return { text: '', labels: [] }
162
- const clamp = (t) => (t.length > budgetChars ? t.slice(0, budgetChars) + '\n…(截断)' : t)
332
+ if (!total.length) return { text: '', entry, labels, reasons, dropped }
333
+ const clamp = (t) => (t.length > budgetChars ? `${t.slice(0, budgetChars)}\n…(截断)` : t)
163
334
  const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
164
- return { text: clamp(total.join('\n')), labels, dedupeText }
335
+ return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, dedupeText, itemChars: used - entry.length }
165
336
  }
166
337
 
167
338
  /** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。
@@ -172,9 +343,54 @@ export function installAutoInject(ctx, config) {
172
343
  const auto = (config && config.autoContext) || {}
173
344
  if (auto.enabled === false) return
174
345
  const cfg = cfgEngine(config)
346
+ // 审计配置同 cfg:安装时解析一次(与 autoContext 其余开关一致)。
347
+ const audit = cfgAudit(config)
175
348
  // 每个会话一份“上次注入指纹”。用单个变量会让并发会话互相抑制注入。
176
349
  const lastFpBySession = new Map()
177
350
  const LAST_FP_MAX = 200
351
+ // 每个会话一份"上次预算丢弃指纹":预算把条目挤出去时必须留痕一次,而不是静默(degraded 可见性)。
352
+ const lastDroppedBySession = new Map()
353
+ // 条目级去重记忆:sessionId → Map(insightId → { hash, step })。注入的消息留在会话历史里
354
+ // (宿主只追加、不压缩),所以"整块指纹变了"不等于"内容都是新的"——同一份 procedure 会因为
355
+ // 滑动工具窗口、任务卡更新、预算截断边界变化被整块重发(实测 66 步注入 20 次,其中同一份
356
+ // 1732 字 procedure 重发 3 次、另一份 1008 字的 6 次)。这里按条目记账,正文没变就不再排程。
357
+ const injectedBySession = new Map()
358
+ const INJECTED_ITEMS_MAX = 600
359
+ // 会话步数:为 reinjectItemsAfter 提供时间轴(>0 时才用得上)。
360
+ const stepBySession = new Map()
361
+ // 会话级条目额度(S3):条数 / 字符 / 上次"条目注入"的步号。常驻任务卡不占这个额度——
362
+ // 它是状态快照,内容变了就该更新;被限流的是记忆条目的推送。
363
+ const budgetBySession = new Map()
364
+ // 反应窗口:本会话最近观察到的 tool/call(参数里有 git commit / npm publish / 改动的路径)。
365
+ // 宿主没有"工具执行前拦截"钩子,所以这是 pre-step 之外唯一能拿到的动作事实。
366
+ const observedBySession = new Map()
367
+ const OBSERVED_MAX = 8
368
+ const OBSERVED_SESSIONS = 200
369
+ ctx.on('session/event', (session, event) => {
370
+ try {
371
+ if (!event || event.type !== 'tool/call') return
372
+ const sid = session && session.id
373
+ if (!sid) return
374
+ const data = event.data || {}
375
+ const rec = observedBySession.get(sid) || []
376
+ rec.push({ name: String(data.name || ''), arguments: String(data.arguments || '') })
377
+ while (rec.length > OBSERVED_MAX) rec.shift()
378
+ observedBySession.set(sid, rec)
379
+ while (observedBySession.size > OBSERVED_SESSIONS) {
380
+ observedBySession.delete(observedBySession.keys().next().value)
381
+ }
382
+ } catch {
383
+ // 观察失败绝不影响宿主请求
384
+ }
385
+ })
386
+ if (typeof ctx.effect === 'function') {
387
+ ctx.effect(() => () => {
388
+ observedBySession.clear()
389
+ injectedBySession.clear()
390
+ stepBySession.clear()
391
+ budgetBySession.clear()
392
+ })
393
+ }
178
394
  ctx.on('agent/pre-step', async (payload, next) => {
179
395
  // 宿主契约是 waterfall(payload, next),next 一定存在;但一旦宿主版本漂移、或事件被当
180
396
  // 普通事件调用,next 缺失会让本监听器 reject(历史事故正是 `next is not a function`)。
@@ -191,18 +407,85 @@ export function installAutoInject(ctx, config) {
191
407
  const sessionId = session && session.id
192
408
  if (!root || !sessionId) return decision
193
409
  const query = lastUserText(decision.messages)
194
- const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
410
+ const memoryRoot = memoryRootFor(root, config.memoryDir)
411
+ const store = new ProjectMemoryStore(memoryRoot).load()
195
412
  const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
196
413
  const boundTaskId = store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
197
414
  const task = boundTaskId ? store.getTask(boundTaskId) : null
198
- const built = buildInjection({ query: query || '', task, store, globalStore, projectTagsList: projectTags(root), cfg })
415
+ // 两个窗口合并进同一个就绪上下文:人类消息(先发)+ 已观察动作(反应)。
416
+ // 动作平面(S1):把"最近做过什么"解析成 op / 写目标 / 主机——不再把原始参数当文本搜。
417
+ const observed = observedBySession.get(sessionId) || []
418
+ const activity = activityFromCalls(observed)
419
+ const readiness = buildReadinessContext({
420
+ humanText: query || '',
421
+ actionText: observed.map((c) => `${c.name} ${c.arguments}`).join('\n'),
422
+ ops: activity.ops,
423
+ targets: activity.targets,
424
+ hosts: activity.hosts,
425
+ })
426
+ // 条目级去重:本会话已注入过、且正文未变的条目不再参与排程(cfg.reinjectItemsAfter=0 时永久,
427
+ // >0 时走冷却步数,用于历史可能被外部裁剪的场景)。正文变了(编辑过 insight)立刻允许重发。
428
+ const stepNo = (stepBySession.get(sessionId) || 0) + 1
429
+ stepBySession.set(sessionId, stepNo)
430
+ while (stepBySession.size > LAST_FP_MAX) stepBySession.delete(stepBySession.keys().next().value)
431
+ const seen = injectedBySession.get(sessionId) || new Map()
432
+ injectedBySession.set(sessionId, seen)
433
+ while (injectedBySession.size > LAST_FP_MAX) injectedBySession.delete(injectedBySession.keys().next().value)
434
+ const cooldown = cfg.reinjectItemsAfter || 0
435
+ const skipItem = cooldown === 0
436
+ ? (it) => {
437
+ const rec = seen.get(it.id)
438
+ return Boolean(rec) && rec.hash === insightItemHash(it)
439
+ }
440
+ : (it) => {
441
+ const rec = seen.get(it.id)
442
+ return Boolean(rec) && rec.hash === insightItemHash(it) && stepNo - rec.step < cooldown
443
+ }
444
+ // 会话级限流(S3):冷却步数 / 条目条数 / 条目字符。三个任一触顶 → 条目通道整体沉默,
445
+ // 但本轮"本该注入什么"仍会进 dropped(不做静默降级)。
446
+ const budget = budgetBySession.get(sessionId) || { items: 0, chars: 0, lastStep: -Infinity }
447
+ budgetBySession.set(sessionId, budget)
448
+ while (budgetBySession.size > LAST_FP_MAX) budgetBySession.delete(budgetBySession.keys().next().value)
449
+ const cooling = Number.isFinite(budget.lastStep) && stepNo - budget.lastStep < cfg.gateCooldownSteps
450
+ const itemsLeft = Math.max(0, cfg.maxItemsPerSession - budget.items)
451
+ const charsLeft = Math.max(0, cfg.maxItemCharsPerSession - budget.chars)
452
+ const silenceReason = cooling ? 'cooldown'
453
+ : itemsLeft === 0 ? 'session-items'
454
+ : charsLeft === 0 ? 'session-chars' : null
455
+ const built = buildInjection({
456
+ query: query || '',
457
+ readiness,
458
+ task,
459
+ store,
460
+ globalStore,
461
+ projectTagsList: projectTags(root),
462
+ cfg,
463
+ skipItem,
464
+ skipItems: silenceReason !== null,
465
+ silenceReason: silenceReason || 'cooldown',
466
+ maxItems: itemsLeft,
467
+ maxChars: charsLeft,
468
+ })
199
469
  const fp = fingerprint(built.dedupeText ?? built.text)
200
- if (built.text && fp !== lastFpBySession.get(sessionId)) {
470
+ const shouldInject = Boolean(built.text) && fp !== lastFpBySession.get(sessionId)
471
+ let injectMessage = null
472
+ if (shouldInject) {
201
473
  // 以宿主 createUserMessage 构造的完整 user 消息追加(带 id/source,plan-mode narration 同款)。
202
474
  // 裸 {role,content} 消息缺 source 会让宿主逐条读 message.source.kind 时崩溃。
203
475
  lastFpBySession.set(sessionId, fp)
204
476
  if (lastFpBySession.size > LAST_FP_MAX) lastFpBySession.delete(lastFpBySession.keys().next().value)
205
- const injectMessage = createUserMessage({
477
+ // 只记真的进了上下文的那几条:dropped 的没被看到,不能记账(否则以后永远不再注入)。
478
+ for (const r of built.reasons) {
479
+ if (r && r.id && r.hash) seen.set(r.id, { hash: r.hash, step: stepNo })
480
+ }
481
+ while (seen.size > INJECTED_ITEMS_MAX) seen.delete(seen.keys().next().value)
482
+ // 会话额度只被"条目"消耗;任务卡不算(否则任务一多就把记忆挤没了)。
483
+ if (built.reasons.length) {
484
+ budget.items += built.reasons.length
485
+ budget.chars += typeof built.itemChars === 'number' ? built.itemChars : 0
486
+ budget.lastStep = stepNo
487
+ }
488
+ injectMessage = createUserMessage({
206
489
  content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` }],
207
490
  // 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
208
491
  // 宿主 ContextFormed 是判别联合:snapshot 必须带 sections(notice 才需要 summary)。
@@ -214,6 +497,39 @@ export function installAutoInject(ctx, config) {
214
497
  sections: [{ name: 'project-memory', text: built.text }],
215
498
  },
216
499
  })
500
+ }
501
+ // 未注入 ≠ 无事发生:因预算被挤掉的条目按 cfg.budgetLog 留痕(默认 off,见 cfgEngine)。
502
+ // 留痕仍然记账(去重 + 上限),只是默认不外泄到用户的终端。
503
+ if (built.dropped && built.dropped.length && cfg.budgetLog !== 'off') {
504
+ const sig = built.dropped.map((d) => `${d.id}:${d.reason}`).join(',')
505
+ if (lastDroppedBySession.get(sessionId) !== sig) {
506
+ const seenBefore = lastDroppedBySession.has(sessionId)
507
+ lastDroppedBySession.set(sessionId, sig)
508
+ while (lastDroppedBySession.size > LAST_FP_MAX) {
509
+ lastDroppedBySession.delete(lastDroppedBySession.keys().next().value)
510
+ }
511
+ // once:只有本会话第一次丢弃出声,之后继续记账但保持安静。
512
+ if (cfg.budgetLog === 'all' || !seenBefore) {
513
+ console.error(
514
+ `[dsh-project-memory] auto-inject degraded: ${built.dropped.length} insight(s) kept out by budget — `
515
+ + built.dropped.map((d) => `${d.id}(${d.reason})`).join(', '),
516
+ )
517
+ }
518
+ }
519
+ }
520
+ if (injectMessage) {
521
+ // S0 观测:只记真的进了上下文的那一次(dropped 单独出现不写,否则每步刷屏)。
522
+ appendInjectionAudit(memoryRoot, auditRecordFrom({
523
+ sessionId,
524
+ root,
525
+ step: stepNo,
526
+ text: built.text,
527
+ labels: built.labels,
528
+ reasons: built.reasons,
529
+ dropped: built.dropped,
530
+ budget: { items: budget.items, chars: budget.chars, lastStep: Number.isFinite(budget.lastStep) ? budget.lastStep : null },
531
+ silence: silenceReason,
532
+ }), audit)
217
533
  return { ...decision, messages: [...decision.messages, injectMessage] }
218
534
  }
219
535
  } catch (err) {
@@ -75,12 +75,14 @@ export function MemoryView({
75
75
  sessionId,
76
76
  scope,
77
77
  boundTaskId,
78
+ showHints,
78
79
  t,
79
80
  }: {
80
81
  ctx: any
81
82
  sessionId: string | null
82
83
  scope: InsightScope
83
84
  boundTaskId: string | null
85
+ showHints: boolean
84
86
  t: any
85
87
  }) {
86
88
  const [payload, setPayload] = useState<ListPayload | null>(null)
@@ -258,7 +260,7 @@ export function MemoryView({
258
260
  {scope !== 'task' && !showNew && (
259
261
  <Button variant="outline" size="sm" disabled={busy} onClick={() => { setShowNew(true); setEditingId(null); setError(null) }}>{t('mem.new')}</Button>
260
262
  )}
261
- <Button variant="outline" size="sm" onClick={() => void refresh(true)} disabled={loading} title={t('panel.refresh')} aria-label={t('panel.refresh')}>
263
+ <Button variant="outline" size="sm" onClick={() => void refresh(true)} disabled={loading} title={showHints ? t('panel.refresh') : undefined} aria-label={t('panel.refresh')}>
262
264
  ↻
263
265
  </Button>
264
266
  </span>
@@ -329,7 +331,7 @@ export function MemoryView({
329
331
  {scope === 'project' && (
330
332
  <>
331
333
  <Button variant="outline" size="sm" disabled={busy} onClick={() => void act('promote', row.id)}>{t('mem.promote')}</Button>
332
- <Button variant="outline" size="sm" disabled={busy || !canDemoteToTask} title={!canDemoteToTask ? t('mem.needs-bound') : undefined} onClick={() => void act('demote', row.id)}>{t('mem.demote')}</Button>
334
+ <Button variant="outline" size="sm" disabled={busy || !canDemoteToTask} title={showHints && !canDemoteToTask ? t('mem.needs-bound') : undefined} onClick={() => void act('demote', row.id)}>{t('mem.demote')}</Button>
333
335
  </>
334
336
  )}
335
337
  {scope === 'global' && (
@@ -117,13 +117,14 @@ function InlineEditor({ value, className, maxRows = 8, onValueChange, onCommit,
117
117
  interface MiniBarProps {
118
118
  label: string
119
119
  hint: string
120
+ showHints: boolean
120
121
  position: { x: number; y: number }
121
122
  theme: string
122
123
  onMove: (pos: { x: number; y: number }) => void
123
124
  open: () => void
124
125
  }
125
126
 
126
- export function MiniBar({ label, hint, position, theme, onMove, open }: MiniBarProps) {
127
+ export function MiniBar({ label, hint, showHints, position, theme, onMove, open }: MiniBarProps) {
127
128
  const barRef = useRef<HTMLDivElement>(null)
128
129
  const down = useRef<{ dx: number; dy: number; sx: number; sy: number; moved: boolean } | null>(null)
129
130
 
@@ -163,7 +164,7 @@ export function MiniBar({ label, hint, position, theme, onMove, open }: MiniBarP
163
164
  data-theme={theme === 'native' ? undefined : theme}
164
165
  onMouseDown={onMouseDown}
165
166
  role="button"
166
- title={hint}
167
+ title={showHints ? hint : undefined}
167
168
  aria-label={hint}
168
169
  >
169
170
  <IconFolderOpenOutline16 className={css.miniIcon} />
@@ -293,7 +294,7 @@ export function TaskCard({
293
294
  <span
294
295
  data-card-title
295
296
  className={`${css.cardTitle}${isBound ? ` ${css.cardTitleEditable}` : ''}`}
296
- title={isBound ? t('task.title-edit') : undefined}
297
+ title={isBound && showHints ? t('task.title-edit') : undefined}
297
298
  onDoubleClick={(e) => {
298
299
  e.preventDefault()
299
300
  e.stopPropagation()
@@ -350,7 +351,7 @@ export function TaskCard({
350
351
  className={css.stepToggle}
351
352
  disabled={syncing}
352
353
  onClick={(e) => { e.stopPropagation(); onCycleStatus(i) }}
353
- title={`${t('step.completed')}/${t('step.in-progress')}/${t('step.pending')}`}
354
+ title={showHints ? `${t('step.completed')}/${t('step.in-progress')}/${t('step.pending')}` : undefined}
354
355
  >
355
356
  <StepIcon status={step.status} />
356
357
  </button>
@@ -423,7 +424,7 @@ export function TaskCard({
423
424
  <button
424
425
  className={css.fileRow}
425
426
  onClick={() => navigator.clipboard?.writeText(file.path)}
426
- title={`${t('file.copy')}: ${file.path}${file.line ? `:${file.line}` : ''}`}
427
+ title={showHints ? `${t('file.copy')}: ${file.path}${file.line ? `:${file.line}` : ''}` : undefined}
427
428
  >
428
429
  <span className={css.fileDot} />
429
430
  <span className={css.filePath}>{file.path}</span>
@@ -476,7 +477,7 @@ export function TaskCard({
476
477
  {t('task.switch')}
477
478
  </Button>
478
479
  {isBound && (
479
- <Button variant="outline" size="sm" disabled={syncing} onClick={onUnbind} title={t('task.unbind')}>
480
+ <Button variant="outline" size="sm" disabled={syncing} onClick={onUnbind} title={showHints ? t('task.unbind') : undefined}>
480
481
  {t('task.unbind')}
481
482
  </Button>
482
483
  )}
@@ -83,6 +83,13 @@
83
83
  gap: 2px;
84
84
  margin-left: auto;
85
85
  }
86
+ /* 提示信息开关处于「已隐藏」状态:图标变暗,给出无需悬停的可见反馈 */
87
+ .hintsOff {
88
+ opacity: 0.45;
89
+ }
90
+ .hintsOff:hover {
91
+ opacity: 0.75;
92
+ }
86
93
  .counts {
87
94
  font-size: 11px;
88
95
  line-height: 16px;