@yolk_vat-y/dsh-project-memory 0.5.6 → 0.5.8
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 +153 -0
- package/README.md +37 -34
- package/README.zh-CN.md +35 -34
- package/client/client.js +458 -107
- package/client/client.js.map +1 -1
- package/package.json +3 -6
- package/src/audit.js +63 -0
- package/src/auto-inject.js +447 -286
- package/src/chunker.js +14 -6
- package/src/client/MemoryView.tsx +12 -4
- package/src/client/TaskCommandNode.tsx +1 -1
- package/src/client/TaskComponents.tsx +3 -2
- package/src/client/TaskPanel.tsx +13 -13
- package/src/client/client.ts +93 -21
- package/src/client/icons.ts +63 -0
- package/src/client/locales.ts +11 -0
- package/src/client/session-id.js +88 -0
- package/src/client/slash.ts +184 -0
- package/src/commands/insight-actions.js +31 -16
- package/src/commands/invocation.js +36 -0
- package/src/commands/task-actions.js +36 -40
- package/src/commands/tasks.js +18 -13
- package/src/commands/workflow.js +54 -0
- package/src/enhancer.js +11 -2
- package/src/index-pipeline.js +119 -0
- package/src/index.js +24 -8
- package/src/insight-store.js +96 -28
- package/src/lazy.js +15 -46
- package/src/link.js +10 -1
- package/src/parsers/pdfjs-parser.js +2 -4
- package/src/project-profile.js +12 -5
- package/src/readiness.js +27 -3
- package/src/recall.js +28 -8
- package/src/setup/taskbridge.js +11 -9
- package/src/store.js +56 -17
- package/src/symbols.js +58 -19
- package/src/tools/forget.js +2 -1
- package/src/tools/index-doc.js +4 -1
- package/src/tools/index-repo.js +33 -86
- package/src/tools/lesson-tools.js +2 -1
- package/src/tools/query-memory.js +29 -4
- package/src/tools/remember.js +3 -1
- package/src/tools/task-tools.js +4 -1
- package/src/tools/watch-repo.js +9 -5
- package/src/util/fs.js +14 -0
- package/src/util/session-cache.js +72 -0
- package/src/watch.js +39 -81
package/src/auto-inject.js
CHANGED
|
@@ -9,18 +9,49 @@ import { createHash } from 'node:crypto'
|
|
|
9
9
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
10
10
|
import { ProjectMemoryStore } from './store.js'
|
|
11
11
|
import { memoryRootFor } from './util/fs.js'
|
|
12
|
-
import { insightMatchText } from './similarity.js'
|
|
13
|
-
import {
|
|
14
|
-
import { cfgInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
|
|
12
|
+
import { insightMatchText, normalizedTokenOverlap } from './similarity.js'
|
|
13
|
+
import { BoundedMap, SessionCache } from './util/session-cache.js'
|
|
14
|
+
import { cfgInsight, GlobalStore, defaultGlobalFile, recordHit } from './insight-store.js'
|
|
15
15
|
import { projectTags } from './project-profile.js'
|
|
16
16
|
import { rankEntriesMergedScored } from './util/search.js'
|
|
17
|
-
import { insightToEntry } from './recall.js'
|
|
17
|
+
import { insightToEntry, insightScoringText } from './recall.js'
|
|
18
18
|
import { buildReadinessContext, hintQueryText, idfCoverage, matchTrigger, normalizeTrigger, relativeHits } from './readiness.js'
|
|
19
19
|
import { activityFromCalls } from './ops.js'
|
|
20
|
-
import { appendInjectionAudit, auditRecordFrom, cfgAudit } from './audit.js'
|
|
20
|
+
import { appendInjectionAudit, appendShadowAudit, auditRecordFrom, cfgAudit, cfgShadow, shadowRecordFrom } from './audit.js'
|
|
21
21
|
|
|
22
22
|
export const INJECT_MARK = '[Memory Inject]'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 本插件在 `message.source` 上声明的生产者 kind。
|
|
26
|
+
*
|
|
27
|
+
* 会话格式 v4 起 `MessageSourceMap` 是「每个生产者声明自己的 kind」的可合并联合类型,
|
|
28
|
+
* **没有** `plugin` 这个兜底 kind:宿主的 `assertV4MessageSources` / `assertV4SourceRowAdmission`
|
|
29
|
+
* 会直接拒绝 `kind === 'plugin'` 的消息(`session-format-v3-to-v4/src/message-sources.ts`),
|
|
30
|
+
* 而拒绝发生在编码落盘那一刻 —— 于是整轮运行失败。
|
|
31
|
+
*
|
|
32
|
+
* 值选 `plugin:<包名>` 是因为这正是宿主自己的 v3→v4 迁移对本插件历史消息的改写结果
|
|
33
|
+
* (`rewritePluginSource`:第三方插件 → `plugin:${plugin}`,并丢弃 `plugin` 字段)。
|
|
34
|
+
* 新写的消息与迁移后的旧消息因此是**同一个生产者身份**,自激闸门(见 isOwnInjection)不需要按新旧分叉。
|
|
35
|
+
*/
|
|
36
|
+
export const SOURCE_KIND = 'plugin:dsh-project-memory'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 这条消息是不是本插件自己注入的?
|
|
40
|
+
*
|
|
41
|
+
* 三种写法都要认,因为同一条历史消息在不同阶段形状不同:
|
|
42
|
+
* - `plugin:dsh-project-memory` —— 现在写的,也是 v3→v4 迁移改写老消息的结果;
|
|
43
|
+
* - `project-memory` —— 保留的别名:将来若再换 kind,自激闸门不会因此失效;
|
|
44
|
+
* - `plugin: 'dsh-project-memory'` —— 迁移前内存里尚未落盘的旧形状(老宿主 / 测试夹具)。
|
|
45
|
+
* @param {object|undefined|null} source - message.source
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isOwnInjection(source) {
|
|
49
|
+
if (!source || typeof source !== 'object') return false
|
|
50
|
+
return source.kind === SOURCE_KIND
|
|
51
|
+
|| source.kind === 'project-memory'
|
|
52
|
+
|| source.plugin === 'dsh-project-memory'
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
/**
|
|
25
56
|
* 注入正文的最小可用长度:预算塞不下这么多就宁可不注入(记 dropped)。
|
|
26
57
|
* 与其输出 `- [ins_xxx] procedure ` 这种 stub,不如保持沉默——stub 只消耗 token 不传递信息。
|
|
@@ -42,6 +73,8 @@ export function cfgEngine(config) {
|
|
|
42
73
|
relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : null,
|
|
43
74
|
// resident 任务卡最多显示几个"编辑中"文件(纯写权重,最近写优先)
|
|
44
75
|
editedMax: typeof c.editedMax === 'number' ? c.editedMax : 3,
|
|
76
|
+
// 常驻任务卡总开关(entryOn:false = 只做条目注入,不回声任务卡)
|
|
77
|
+
entryOn: c.entryOn !== false,
|
|
45
78
|
// 模型自己写/维护任务清单后、尚无新人类消息时,不把任务卡再回声给模型(省 token)
|
|
46
79
|
skipEchoSelfTodo: c.skipEchoSelfTodo !== false,
|
|
47
80
|
// 预算审计日志级别(stderr)。默认 off:预算挤掉低优先级条目是正常降级,不是故障,
|
|
@@ -65,9 +98,13 @@ export function cfgEngine(config) {
|
|
|
65
98
|
// 每会话条目注入的上限(条数 / 字符):预算只能是上限,不是目标。
|
|
66
99
|
maxItemsPerSession: typeof c.maxItemsPerSession === 'number' && c.maxItemsPerSession >= 0 ? c.maxItemsPerSession : 12,
|
|
67
100
|
maxItemCharsPerSession: typeof c.maxItemCharsPerSession === 'number' && c.maxItemCharsPerSession >= 0 ? c.maxItemCharsPerSession : 4000,
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
101
|
+
// 提示通道的**绝对**覆盖底线(IDF 加权覆盖率):相对阈值分不出"有信号"和"矮子里拔将军",
|
|
102
|
+
// 只有归一在 [0,1]、有真零点才谈得上下限。0.30 时的实测反例:真实 store(43 条同源洞察)上,
|
|
103
|
+
// 对照组场景「改 pptx 时间戳」以 cov 0.32~0.35 注入了 3 条无关条目——语料同源时共享词多、
|
|
104
|
+
// IDF 分辨力被拉平,"最不坏的一条"就能过 0.30。0.45 落在实测分布的空隙里(假阳性 ≤0.35、
|
|
105
|
+
// 下一个真命中 ≥0.49),合成标注集在 0.6 以前 precision/recall 也仍是 1.00。
|
|
106
|
+
// 用 `--hint-cov` 在真实 store 上重放可复核(见 test/injection-scenarios.test.mjs)。
|
|
107
|
+
hintMinCoverage: typeof c.hintMinCoverage === 'number' && c.hintMinCoverage >= 0 ? c.hintMinCoverage : 0.45,
|
|
71
108
|
// 提示通道还要求至少这么多个共同词:单个通用词("插件")也能拿到 coverage=1.00。
|
|
72
109
|
hintMinMatched: typeof c.hintMinMatched === 'number' && c.hintMinMatched >= 0 ? c.hintMinMatched : 2,
|
|
73
110
|
// 通道级沉默:查询里能在语料中找到对应的词占比低于这个值时,整条提示通道本轮不出声。
|
|
@@ -95,6 +132,9 @@ export function lastUserText(messages) {
|
|
|
95
132
|
// 真人消息优先:本插件注入的块也是 role=user,若按"最后一条 user"取,
|
|
96
133
|
// 上一步的注入块会变成这一步的就绪查询(自激:拿自己注入的内容再检索一遍)。
|
|
97
134
|
if (m.source && m.source.kind === 'user') return t.trim()
|
|
135
|
+
// 本插件注入的块同样是 role=user。无 source 的老宿主下若把它当兜底查询,
|
|
136
|
+
// 就成了"拿自己上一步注入的内容再检索一遍"的自激——正是 isOwnInjection 这道闸要防的。
|
|
137
|
+
if (isOwnInjection(m.source)) continue
|
|
98
138
|
if (!fallback) fallback = t.trim() // 无 source 的消息(老宿主 / 测试)兜底
|
|
99
139
|
}
|
|
100
140
|
return fallback
|
|
@@ -142,12 +182,20 @@ export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
|
|
|
142
182
|
if (!task) return ''
|
|
143
183
|
const c = cfg || {}
|
|
144
184
|
const steps = (task.steps || []).map((s) => (typeof s === 'string' ? s : s.content || s.text || '').slice(0, 80))
|
|
145
|
-
const
|
|
185
|
+
const progress = steps.length ? `${steps.length} 步` : ''
|
|
186
|
+
const card = [
|
|
187
|
+
`任务: ${task.title || '(untitled)'}`,
|
|
188
|
+
`进度: ${progress}`,
|
|
189
|
+
...steps.slice(0, 12).map((s, i) => ` ${i + 1}. ${s}`),
|
|
190
|
+
]
|
|
146
191
|
const insights = linesOf(task.insights)
|
|
147
|
-
|
|
192
|
+
// 0 是合法值(= 不显示):cfgEngine 已归一成数字,这里不能再用 `||` 把 0 顶回默认。
|
|
193
|
+
const maxEdited = Number.isFinite(c.editedMax) ? c.editedMax : 3
|
|
194
|
+
const maxInsights = Number.isFinite(c.entryMaxInsights) ? c.entryMaxInsights : 6
|
|
195
|
+
const edited = editedFiles(task, maxEdited)
|
|
148
196
|
const parts = [...card]
|
|
149
197
|
if (withEdited && edited.length) parts.push(` 编辑中: ${edited.join(', ')}`)
|
|
150
|
-
if (insights.length) parts.push(`任务记忆:`, ...insights.slice(0,
|
|
198
|
+
if (insights.length) parts.push(`任务记忆:`, ...insights.slice(0, maxInsights))
|
|
151
199
|
return parts.join('\n')
|
|
152
200
|
}
|
|
153
201
|
|
|
@@ -183,11 +231,118 @@ export function insightItemHash(it) {
|
|
|
183
231
|
|
|
184
232
|
/** 把正文塞进剩余预算:放不下就截断;连"有用前缀"都留不下就返回 null(由调用方记 dropped)。 */
|
|
185
233
|
export function fitBody(body, remaining, min = MIN_BODY_CHARS.hint) {
|
|
186
|
-
if (remaining
|
|
234
|
+
if (remaining < min) return null
|
|
187
235
|
if (body.length <= remaining) return body
|
|
188
236
|
return `${body.slice(0, remaining - 1)}…`
|
|
189
237
|
}
|
|
190
238
|
|
|
239
|
+
/**
|
|
240
|
+
* 通道 1:authored trigger 命中(确定性、全文、优先级 1)。procedure 先于其它 kind(步骤更完整)。
|
|
241
|
+
* 准入化(S2):只有 `when`(op / 写目标 / 意图词)能触发,guard 只能收窄;
|
|
242
|
+
* **没有 `when` 的条目在这里永远不命中**(降级为可发现 + 按需拉取)。
|
|
243
|
+
*/
|
|
244
|
+
function collectTriggered(cands, ctx) {
|
|
245
|
+
const hits = []
|
|
246
|
+
for (const it of cands) {
|
|
247
|
+
const why = matchTrigger(it.trigger, ctx)
|
|
248
|
+
if (why) hits.push({ it, why })
|
|
249
|
+
}
|
|
250
|
+
hits.sort(
|
|
251
|
+
(a, b) => (a.it.kind === 'procedure' ? 0 : 1) - (b.it.kind === 'procedure' ? 0 : 1)
|
|
252
|
+
|| String(a.it.id).localeCompare(String(b.it.id)),
|
|
253
|
+
)
|
|
254
|
+
return hits
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 通道 2:统计信号(提示态、优先级 2)。**双门槛**:层内相对阈值 + IDF 加权覆盖率(绝对下限)。
|
|
259
|
+
* 查询只用「人类消息的意图文字 + 本次写目标」:原始工具参数不是查询文本,它们正是
|
|
260
|
+
* `dcterms→rm`、`*.pptx→论文笔记` 那类假阳性的来源。被门槛拦下的记进 dropped,不静默。
|
|
261
|
+
*/
|
|
262
|
+
function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
263
|
+
const hints = []
|
|
264
|
+
// 影子记录用:本步**全部**被评过分的候选 + 特征 + 判据结果。与 hints/dropped 分开算——
|
|
265
|
+
// 现有两类记录都只覆盖"过得了相对阈值的那部分",恰恰缺了"因为没到阈值而从未被考虑"的候选,
|
|
266
|
+
// 而那正是离线重放阈值时需要的那批。这里只做加法,不动任何现有判据。
|
|
267
|
+
const candidates = []
|
|
268
|
+
if (!cands.length) return { hints, candidates }
|
|
269
|
+
if (typeof cfg.relevanceMin === 'number') {
|
|
270
|
+
// 兼容:显式 relevanceMin → 旧的绝对 overlap 判据(老 profile 行为不变)
|
|
271
|
+
for (const it of cands) {
|
|
272
|
+
const score = normalizedTokenOverlap(humanText || query || '', insightMatchText(it))
|
|
273
|
+
if (score >= cfg.relevanceMin) hints.push({ it, score, why: `overlap:${score.toFixed(3)}` })
|
|
274
|
+
}
|
|
275
|
+
hints.sort((a, b) => b.score - a.score)
|
|
276
|
+
return { hints, candidates } // 旧判据路径不产出影子候选(已废弃,无重放价值)
|
|
277
|
+
}
|
|
278
|
+
const q = hintQueryText(hintQuery)
|
|
279
|
+
if (!q) return { hints, candidates }
|
|
280
|
+
const byId = new Map(cands.map((it) => [it.id, it]))
|
|
281
|
+
// 覆盖率语料必须与 BM25 排序同源(insightScoringText):否则"只在 fix/solution 里有匹配"
|
|
282
|
+
// 的查询词 df=0 → supportRatio=0 → 整条提示通道沉默,而排序明明给了它高分。
|
|
283
|
+
const corpus = cands.map((it) => insightScoringText(it))
|
|
284
|
+
// 查询与被测覆盖率的文本用**同一个**过滤后的查询:否则缩写(wsl/npm)会在 BM25 里被剔除、
|
|
285
|
+
// 却仍在覆盖率里计分,"至少两个共同词"就被它们凑够了。
|
|
286
|
+
const scored = rankEntriesMergedScored(cands.map(insightToEntry), [q], cands.length)
|
|
287
|
+
const coverage = new Map(cands.map((it, i) => [it.id, idfCoverage(q, corpus[i], corpus)]))
|
|
288
|
+
const qStats = coverage.get(cands[0].id) || { supported: 0, terms: 0 }
|
|
289
|
+
const supportRatio = qStats.terms > 0 ? qStats.supported / qStats.terms : 0
|
|
290
|
+
// 通道级沉默:查询里绝大多数词在语料里根本没有对应 → "覆盖率 1.00"只是假象。
|
|
291
|
+
const channelThin = typeof cfg.hintMinSupport === 'number' && supportRatio < cfg.hintMinSupport
|
|
292
|
+
for (const r of relativeHits(scored, { ratioMin: cfg.signalMinRatio })) {
|
|
293
|
+
const it = byId.get(r.entry.insightId)
|
|
294
|
+
if (!it) continue
|
|
295
|
+
const ev = coverage.get(it.id) || { coverage: 0, matched: 0, supported: 0, terms: 0 }
|
|
296
|
+
if (channelThin) {
|
|
297
|
+
dropped.push({ id: it.id, channel: 'hint', reason: `support:${supportRatio.toFixed(2)}` })
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
// 绝对门槛:覆盖率下限 + 共同词数下限(查询本身很短时下限按可用词数收敛)。
|
|
301
|
+
if (typeof cfg.hintMinCoverage === 'number' && ev.coverage < cfg.hintMinCoverage) {
|
|
302
|
+
dropped.push({ id: it.id, channel: 'hint', reason: `coverage:${ev.coverage.toFixed(2)}` })
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
// 共同词数下限按**查询本身的词数**收敛(查询很短时不强求两个),而不是按"语料能表示的词数":
|
|
306
|
+
// 后者在长而杂的查询上会退化成 1,一个碰巧命中的通用词就能过闸。
|
|
307
|
+
const needMatched = Math.min(typeof cfg.hintMinMatched === 'number' ? cfg.hintMinMatched : 2, ev.terms)
|
|
308
|
+
if (ev.matched < needMatched) {
|
|
309
|
+
dropped.push({ id: it.id, channel: 'hint', reason: `thin:${ev.matched}` })
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
hints.push({ it, score: r.score, why: `relative:${(r.score / (scored[0].score || 1)).toFixed(2)} cov:${ev.coverage.toFixed(2)}/${ev.matched}` })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- 影子候选(只读、无副作用):重放一遍**全部**被评分的条目,标出它卡在哪一关 ----
|
|
316
|
+
// 顺序与真实判据一致(support → coverage → thin → relative),最后 'cand' = 过了全部门槛
|
|
317
|
+
// (注意 'cand' 不等于"真的注入了":还要过去重/预算/冷却,那些写在同一行的 injected/silence)。
|
|
318
|
+
{
|
|
319
|
+
const top = scored[0]?.score || 0
|
|
320
|
+
const ratio = typeof cfg.signalMinRatio === 'number' && Number.isFinite(cfg.signalMinRatio) ? cfg.signalMinRatio : 0.5
|
|
321
|
+
for (const r of scored) {
|
|
322
|
+
const it = byId.get(r.entry.insightId)
|
|
323
|
+
if (!it || !Number.isFinite(r.score) || r.score <= 0) continue
|
|
324
|
+
const ev = coverage.get(it.id) || { coverage: 0, matched: 0, supported: 0, terms: 0 }
|
|
325
|
+
const needMatched = Math.min(typeof cfg.hintMinMatched === 'number' ? cfg.hintMinMatched : 2, ev.terms)
|
|
326
|
+
let decision = 'cand'
|
|
327
|
+
if (channelThin) decision = 'support'
|
|
328
|
+
else if (typeof cfg.hintMinCoverage === 'number' && ev.coverage < cfg.hintMinCoverage) decision = 'coverage'
|
|
329
|
+
else if (ev.matched < needMatched) decision = 'thin'
|
|
330
|
+
else if (r.score < top * Math.max(0, Math.min(1, ratio))) decision = 'relative'
|
|
331
|
+
candidates.push({
|
|
332
|
+
id: it.id,
|
|
333
|
+
channel: 'hint',
|
|
334
|
+
rel: Number((r.score / (top || 1)).toFixed(3)),
|
|
335
|
+
coverage: Number(ev.coverage.toFixed(3)),
|
|
336
|
+
matched: ev.matched,
|
|
337
|
+
support: ev.supported,
|
|
338
|
+
terms: ev.terms,
|
|
339
|
+
decision,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { hints, candidates }
|
|
344
|
+
}
|
|
345
|
+
|
|
191
346
|
/**
|
|
192
347
|
* 构建注入内容(不写盘、无副作用)。
|
|
193
348
|
*
|
|
@@ -208,131 +363,138 @@ export function buildInjection(opts) {
|
|
|
208
363
|
const reasons = []
|
|
209
364
|
const dropped = []
|
|
210
365
|
const parts = []
|
|
211
|
-
//
|
|
366
|
+
// 预算只能是上限,不是目标。两条额度分开:
|
|
367
|
+
// stepBudget —— 单轮(常驻块 + 条目)共享的 maxTokens;
|
|
368
|
+
// itemBudget —— 条目还受会话字符额度约束;**常驻块不占这条**(它是状态快照,文档明说不受限)。
|
|
369
|
+
// 旧实现把两者取小成同一个 budgetChars,会话额度用尽后连任务卡都被截成 `…(截断)`。
|
|
212
370
|
const quotaChars = typeof opts.maxChars === 'number' ? opts.maxChars : Infinity
|
|
213
371
|
const maxItems = typeof opts.maxItems === 'number' ? opts.maxItems : Infinity
|
|
214
372
|
const skipItems = opts.skipItems === true
|
|
215
|
-
const
|
|
373
|
+
const stepBudget = Math.max(0, (cfg.maxTokens || 400) * 3)
|
|
374
|
+
const itemBudget = Math.max(0, Math.min(stepBudget, quotaChars))
|
|
216
375
|
|
|
217
376
|
const skipItem = typeof opts.skipItem === 'function' ? opts.skipItem : null
|
|
218
377
|
const cands = insightCandidates(store, globalStore, task, cfg).filter((it) => !(skipItem && skipItem(it)))
|
|
219
378
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
// **没有 `when` 的条目在这里永远不命中**(降级为可发现 + 按需拉取)。
|
|
223
|
-
const triggered = []
|
|
224
|
-
for (const it of cands) {
|
|
225
|
-
const why = matchTrigger(it.trigger, ctx)
|
|
226
|
-
if (!why) continue
|
|
227
|
-
triggered.push({ it, why })
|
|
228
|
-
}
|
|
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
|
-
)
|
|
379
|
+
// 两条通道:authored trigger(确定性、全文、优先级 1)→ 统计信号提示(优先级 2)。
|
|
380
|
+
const triggered = collectTriggered(cands, ctx)
|
|
233
381
|
if (skipItems) {
|
|
234
382
|
// 会话级限流命中:条目通道本轮整体沉默,但把"本该注入什么"记进 dropped——不做静默降级。
|
|
235
|
-
for (const
|
|
383
|
+
for (const { it } of triggered) dropped.push({ id: it.id, channel: 'trigger', reason: opts.silenceReason || 'cooldown' })
|
|
236
384
|
}
|
|
237
|
-
|
|
238
|
-
// 通道 2:统计信号 —— 提示态、优先级 2。**双门槛**:层内相对阈值 + IDF 加权覆盖率(绝对下限)。
|
|
239
|
-
// 查询只用「人类消息的意图文字 + 本次写目标」:原始工具参数不再进查询,它们正是
|
|
240
|
-
// `dcterms→rm`、`*.pptx→论文笔记` 那类假阳性的来源。procedure 不进本通道(要过 trigger.scope)。
|
|
385
|
+
// procedure 不进提示通道:它要过 trigger.scope(见 scoreHints 注释)。
|
|
241
386
|
const consumed = new Set(triggered.map((t) => t.it.id))
|
|
242
387
|
const hintCands = skipItems ? [] : cands.filter((it) => !consumed.has(it.id) && it.kind !== 'procedure')
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
}
|
|
388
|
+
const { hints, candidates } = scoreHints({
|
|
389
|
+
cands: hintCands,
|
|
390
|
+
query,
|
|
391
|
+
humanText: ctx.humanText,
|
|
392
|
+
hintQuery: [ctx.intent || ctx.humanText || '', ...(ctx.targets || [])].filter(Boolean).join(' '),
|
|
393
|
+
cfg,
|
|
394
|
+
dropped,
|
|
395
|
+
})
|
|
287
396
|
|
|
288
397
|
// 常驻块文本。去重指纹只看稳定内容(任务标题/步骤/insights + 相关 insights):
|
|
289
398
|
// “编辑中”随每次写文件变化,若参与指纹会导致每写一个文件就重发整块(噪音 + token 浪费)。
|
|
290
|
-
const echo = shouldEchoTaskCard(task, cfg)
|
|
399
|
+
const echo = cfg.entryOn !== false && shouldEchoTaskCard(task, cfg)
|
|
291
400
|
const entry = echo ? buildEntryContent(task, cfg) : ''
|
|
292
401
|
const entryStable = echo ? buildEntryContent(task, cfg, { withEdited: false }) : ''
|
|
293
402
|
let used = entry.length
|
|
403
|
+
let itemUsed = 0
|
|
294
404
|
let itemCount = 0
|
|
295
405
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
406
|
+
/** 把一组候选按优先级放进剩余预算:放不下的进 dropped(quota / budget),不静默。 */
|
|
407
|
+
const place = (channel, list, minChars, labelOf) => {
|
|
408
|
+
for (const { it, why } of list) {
|
|
409
|
+
if (itemCount >= maxItems) {
|
|
410
|
+
dropped.push({ id: it.id, channel, reason: 'quota' })
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
// 条目同时受"单轮剩余"与"会话字符剩余"约束;常驻块只占前者。
|
|
414
|
+
const remaining = Math.min(stepBudget - used, itemBudget - itemUsed)
|
|
415
|
+
const body = fitBody(insightBody(it), remaining, minChars)
|
|
416
|
+
if (body === null) {
|
|
417
|
+
dropped.push({ id: it.id, channel, reason: 'budget' })
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
used += body.length + 1
|
|
421
|
+
itemUsed += body.length + 1
|
|
422
|
+
itemCount++
|
|
423
|
+
parts.push(body)
|
|
424
|
+
labels.push(labelOf(it))
|
|
425
|
+
reasons.push({ id: it.id, channel, why, hash: insightItemHash(it), chars: body.length })
|
|
306
426
|
}
|
|
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
427
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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 })
|
|
428
|
+
// 限流命中时条目通道整体沉默:triggered 已在上方记进 dropped,这里不再重复排程。
|
|
429
|
+
if (!skipItems) {
|
|
430
|
+
place('trigger', triggered, MIN_BODY_CHARS.trigger, (it) => it.kind)
|
|
431
|
+
place('hint', hints, MIN_BODY_CHARS.hint, () => 'hint')
|
|
329
432
|
}
|
|
330
433
|
|
|
331
434
|
const total = [entry, ...parts].filter(Boolean)
|
|
332
435
|
if (!total.length) return { text: '', entry, labels, reasons, dropped }
|
|
333
|
-
|
|
436
|
+
// 整块只受单轮预算(maxTokens)约束;会话条目额度已在 place() 里单独扣过。
|
|
437
|
+
const clamp = (t) => (t.length > stepBudget ? `${t.slice(0, stepBudget)}\n…(截断)` : t)
|
|
334
438
|
const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
|
|
335
|
-
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, dedupeText, itemChars:
|
|
439
|
+
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, candidates, dedupeText, itemChars: itemUsed }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 每会话状态的会话数上限:六张表共用(旧实现把同一个 200 在五处各写一遍)。
|
|
443
|
+
const SESSION_STATE_MAX = 200
|
|
444
|
+
// 单会话已注入条目表的条数上限。
|
|
445
|
+
const INJECTED_ITEMS_MAX = 600
|
|
446
|
+
// 单会话保留的 tool/call 观察窗口(够覆盖"最近几步在做什么")。
|
|
447
|
+
const OBSERVED_CALLS_MAX = 8
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 注入引擎的「每会话一份」状态。
|
|
451
|
+
*
|
|
452
|
+
* 之前六张表散在 installAutoInject 里、各自 hand-roll 淘汰循环,同一个
|
|
453
|
+
* `while (map.size > CAP) map.delete(map.keys().next().value)` 抄了五遍:容量上限写在五处,
|
|
454
|
+
* 抄漏一处就是无界增长,dispose 时也漏清两张表。容量只在这里定义一次。
|
|
455
|
+
*/
|
|
456
|
+
class InjectionSessions {
|
|
457
|
+
constructor() {
|
|
458
|
+
const cache = (create) => new SessionCache({ maxSessions: SESSION_STATE_MAX, create })
|
|
459
|
+
// 上次注入指纹:用单个变量会让并发会话互相抑制注入。
|
|
460
|
+
this.lastText = cache()
|
|
461
|
+
// 上次预算丢弃签名:预算把条目挤出去时必须留痕一次,而不是静默(degraded 可见性)。
|
|
462
|
+
this.lastDropped = cache()
|
|
463
|
+
// 条目级去重记忆:insightId → { hash, step }。注入的消息留在 append-only 的会话历史里,
|
|
464
|
+
// 所以"整块指纹变了"不等于"内容都是新的":滑动工具窗口 / 任务卡更新 / 预算截断边界都会
|
|
465
|
+
// 让同一份 procedure 被整块重发(实测 66 步注入 20 次,同一份 1732 字重发 3 次)。
|
|
466
|
+
this.items = cache(() => new BoundedMap(INJECTED_ITEMS_MAX))
|
|
467
|
+
// 会话步数:为 reinjectItemsAfter 提供时间轴。
|
|
468
|
+
this.step = cache(() => 0)
|
|
469
|
+
// 会话级条目额度(S3):条数 / 字符 / 上次"条目注入"的步号。常驻任务卡不占额度——
|
|
470
|
+
// 它是状态快照,内容变了就该更新;被限流的是记忆条目的推送。
|
|
471
|
+
this.quota = cache(() => ({ items: 0, chars: 0, lastStep: -Infinity }))
|
|
472
|
+
// 反应窗口:本会话最近观察到的 tool/call(参数里有 git commit / npm publish / 改动的路径)。
|
|
473
|
+
// 宿主没有"工具执行前拦截"钩子,所以这是 pre-step 之外唯一能拿到的动作事实。
|
|
474
|
+
this.observed = cache(() => [])
|
|
475
|
+
this._all = Object.values(this)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
clear() {
|
|
479
|
+
for (const cache of this._all) cache.clear()
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 记录会话里的 tool/call:ops.js 从这些参数解析出 op / 写目标 / 主机。 */
|
|
484
|
+
function installCallObserver(ctx, sessions) {
|
|
485
|
+
ctx.on('session/event', (session, event) => {
|
|
486
|
+
try {
|
|
487
|
+
if (!event || event.type !== 'tool/call') return
|
|
488
|
+
const sessionId = session && session.id
|
|
489
|
+
if (!sessionId) return
|
|
490
|
+
const data = event.data || {}
|
|
491
|
+
const calls = sessions.observed.ensure(sessionId)
|
|
492
|
+
calls.push({ name: String(data.name || ''), arguments: String(data.arguments || '') })
|
|
493
|
+
if (calls.length > OBSERVED_CALLS_MAX) calls.shift()
|
|
494
|
+
} catch {
|
|
495
|
+
// 观察失败绝不影响宿主请求
|
|
496
|
+
}
|
|
497
|
+
})
|
|
336
498
|
}
|
|
337
499
|
|
|
338
500
|
/** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。
|
|
@@ -345,51 +507,12 @@ export function installAutoInject(ctx, config) {
|
|
|
345
507
|
const cfg = cfgEngine(config)
|
|
346
508
|
// 审计配置同 cfg:安装时解析一次(与 autoContext 其余开关一致)。
|
|
347
509
|
const audit = cfgAudit(config)
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
})
|
|
510
|
+
const shadow = cfgShadow(config)
|
|
511
|
+
const sessions = new InjectionSessions()
|
|
512
|
+
|
|
513
|
+
installCallObserver(ctx, sessions)
|
|
386
514
|
if (typeof ctx.effect === 'function') {
|
|
387
|
-
ctx.effect(() => () =>
|
|
388
|
-
observedBySession.clear()
|
|
389
|
-
injectedBySession.clear()
|
|
390
|
-
stepBySession.clear()
|
|
391
|
-
budgetBySession.clear()
|
|
392
|
-
})
|
|
515
|
+
ctx.effect(() => () => sessions.clear())
|
|
393
516
|
}
|
|
394
517
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
395
518
|
// 宿主契约是 waterfall(payload, next),next 一定存在;但一旦宿主版本漂移、或事件被当
|
|
@@ -401,140 +524,178 @@ export function installAutoInject(ctx, config) {
|
|
|
401
524
|
if (!decision) return fallback()
|
|
402
525
|
if (decision.kind !== 'enter') return decision
|
|
403
526
|
try {
|
|
404
|
-
|
|
405
|
-
const session = agent && agent.session
|
|
406
|
-
const root = session && session.header && session.header.cwd
|
|
407
|
-
const sessionId = session && session.id
|
|
408
|
-
if (!root || !sessionId) return decision
|
|
409
|
-
const query = lastUserText(decision.messages)
|
|
410
|
-
const memoryRoot = memoryRootFor(root, config.memoryDir)
|
|
411
|
-
const store = new ProjectMemoryStore(memoryRoot).load()
|
|
412
|
-
const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
413
|
-
const boundTaskId = store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
|
|
414
|
-
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
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
|
-
})
|
|
469
|
-
const fp = fingerprint(built.dedupeText ?? built.text)
|
|
470
|
-
const shouldInject = Boolean(built.text) && fp !== lastFpBySession.get(sessionId)
|
|
471
|
-
let injectMessage = null
|
|
472
|
-
if (shouldInject) {
|
|
473
|
-
// 以宿主 createUserMessage 构造的完整 user 消息追加(带 id/source,plan-mode narration 同款)。
|
|
474
|
-
// 裸 {role,content} 消息缺 source 会让宿主逐条读 message.source.kind 时崩溃。
|
|
475
|
-
lastFpBySession.set(sessionId, fp)
|
|
476
|
-
if (lastFpBySession.size > LAST_FP_MAX) lastFpBySession.delete(lastFpBySession.keys().next().value)
|
|
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({
|
|
489
|
-
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` }],
|
|
490
|
-
// 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
|
|
491
|
-
// 宿主 ContextFormed 是判别联合:snapshot 必须带 sections(notice 才需要 summary)。
|
|
492
|
-
// 通道不变(仍走 agent/pre-step 追加 user 消息),只修语义。
|
|
493
|
-
source: {
|
|
494
|
-
kind: 'plugin',
|
|
495
|
-
plugin: 'dsh-project-memory',
|
|
496
|
-
form: 'snapshot',
|
|
497
|
-
sections: [{ name: 'project-memory', text: built.text }],
|
|
498
|
-
},
|
|
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)
|
|
533
|
-
return { ...decision, messages: [...decision.messages, injectMessage] }
|
|
534
|
-
}
|
|
527
|
+
return (await injectForStep({ payload, decision, config, cfg, audit, shadow, sessions })) || decision
|
|
535
528
|
} catch (err) {
|
|
536
529
|
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
530
|
+
return decision
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* 一个 pre-step 的注入决策:解析会话 → 排程 → 必要时构造消息。
|
|
537
|
+
* 返回 null 表示本步无事可做(交回原决策)。
|
|
538
|
+
*/
|
|
539
|
+
async function injectForStep({ payload, decision, config, cfg, audit, shadow, sessions }) {
|
|
540
|
+
const session = payload && payload.agent && payload.agent.session
|
|
541
|
+
const root = session && session.header && session.header.cwd
|
|
542
|
+
const sessionId = session && session.id
|
|
543
|
+
if (!root || !sessionId) return null
|
|
544
|
+
|
|
545
|
+
const query = lastUserText(decision.messages)
|
|
546
|
+
const memoryRoot = memoryRootFor(root, config.memoryDir)
|
|
547
|
+
const store = new ProjectMemoryStore(memoryRoot).load()
|
|
548
|
+
const globalStore = new GlobalStore(cfg.globalFile || defaultGlobalFile()).load()
|
|
549
|
+
const boundTaskId = store.getBoundTaskId(sessionId)
|
|
550
|
+
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
551
|
+
|
|
552
|
+
// 两个窗口合并进同一个就绪上下文:人类消息(先发)+ 已观察动作(反应)。
|
|
553
|
+
// 动作平面(S1):把"最近做过什么"解析成 op / 写目标 / 主机——不再把原始参数当文本搜。
|
|
554
|
+
const observed = sessions.observed.ensure(sessionId)
|
|
555
|
+
const activity = activityFromCalls(observed)
|
|
556
|
+
const readiness = buildReadinessContext({
|
|
557
|
+
humanText: query || '',
|
|
558
|
+
actionText: observed.map((c) => `${c.name} ${c.arguments}`).join('\n'),
|
|
559
|
+
ops: activity.ops,
|
|
560
|
+
targets: activity.targets,
|
|
561
|
+
hosts: activity.hosts,
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
const stepNo = sessions.step.ensure(sessionId) + 1
|
|
565
|
+
sessions.step.set(sessionId, stepNo)
|
|
566
|
+
const quota = sessions.quota.ensure(sessionId)
|
|
567
|
+
const silence = sessionSilence({ cfg, quota, stepNo })
|
|
568
|
+
// 条目级去重:本会话已注入过、且正文未变的条目不再参与排程(正文被编辑过 → 立刻允许重发)。
|
|
569
|
+
const skipItem = makeSkipItem(sessions.items.ensure(sessionId), stepNo, cfg.reinjectItemsAfter || 0)
|
|
570
|
+
|
|
571
|
+
const built = buildInjection({
|
|
572
|
+
query: query || '',
|
|
573
|
+
readiness,
|
|
574
|
+
task,
|
|
575
|
+
store,
|
|
576
|
+
globalStore,
|
|
577
|
+
projectTagsList: projectTags(root),
|
|
578
|
+
cfg,
|
|
579
|
+
skipItem,
|
|
580
|
+
skipItems: silence.reason !== null,
|
|
581
|
+
silenceReason: silence.reason || 'cooldown',
|
|
582
|
+
maxItems: silence.itemsLeft,
|
|
583
|
+
maxChars: silence.charsLeft,
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// 未注入 ≠ 无事发生:因预算被挤掉的条目按 cfg.budgetLog 留痕(默认 off,见 cfgEngine)。
|
|
587
|
+
// 留痕仍然记账(去重 + 上限),只是默认不外泄到用户的终端。
|
|
588
|
+
recordDropped({ sessions, sessionId, dropped: built.dropped, budgetLog: cfg.budgetLog })
|
|
589
|
+
|
|
590
|
+
// 影子记录:**每步**都写一行(含零注入的静默步与对照组),带全部候选的判据特征。
|
|
591
|
+
// 主审计只在真的注入时写,静默步零痕迹 → 日志无法离线重放"换个阈值会怎样",
|
|
592
|
+
// 也攒不出训练样本。只写盘、不进 prompt、不花 token。
|
|
593
|
+
appendShadowAudit(memoryRoot, shadowRecordFrom({
|
|
594
|
+
sessionId,
|
|
595
|
+
root,
|
|
596
|
+
step: stepNo,
|
|
597
|
+
query: query || '',
|
|
598
|
+
ops: readiness.ops,
|
|
599
|
+
writes: readiness.targets,
|
|
600
|
+
reasons: built.reasons,
|
|
601
|
+
candidates: built.candidates,
|
|
602
|
+
silence: silence.reason,
|
|
603
|
+
}), shadow)
|
|
604
|
+
|
|
605
|
+
const fp = fingerprint(built.dedupeText ?? built.text)
|
|
606
|
+
if (!built.text || fp === sessions.lastText.peek(sessionId)) return null
|
|
607
|
+
sessions.lastText.set(sessionId, fp)
|
|
608
|
+
|
|
609
|
+
// 只记真的进了上下文的那几条:dropped 的没被看到,不能记账(否则以后永远不再注入)。
|
|
610
|
+
const seen = sessions.items.ensure(sessionId)
|
|
611
|
+
for (const r of built.reasons) {
|
|
612
|
+
if (r && r.id && r.hash) seen.set(r.id, { hash: r.hash, step: stepNo })
|
|
613
|
+
}
|
|
614
|
+
// 会话额度只被"条目"消耗;任务卡不算(否则任务一多就把记忆挤没了)。
|
|
615
|
+
if (built.reasons.length) {
|
|
616
|
+
quota.items += built.reasons.length
|
|
617
|
+
quota.chars += typeof built.itemChars === 'number' ? built.itemChars : 0
|
|
618
|
+
quota.lastStep = stepNo
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 使用记账:注入进上下文 = 这条记忆被用到了。必须在这里显式落盘——注入路径不会走到
|
|
622
|
+
// 任何其它 save(),只标脏等于进程退出就丢。记账失败绝不影响注入(与审计同约定)。
|
|
623
|
+
if (built.reasons.length) {
|
|
624
|
+
try {
|
|
625
|
+
if (recordHit({ store, globalStore, ids: built.reasons.map((r) => r.id) })) {
|
|
626
|
+
store.save()
|
|
627
|
+
globalStore.commit()
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
/* ignore:记账是旁路,不能拖累注入 */
|
|
537
631
|
}
|
|
538
|
-
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// S0 观测:只记真的进了上下文的那一次(dropped 单独出现不写,否则每步刷屏)。
|
|
635
|
+
appendInjectionAudit(memoryRoot, auditRecordFrom({
|
|
636
|
+
sessionId,
|
|
637
|
+
root,
|
|
638
|
+
step: stepNo,
|
|
639
|
+
text: built.text,
|
|
640
|
+
labels: built.labels,
|
|
641
|
+
reasons: built.reasons,
|
|
642
|
+
dropped: built.dropped,
|
|
643
|
+
budget: { items: quota.items, chars: quota.chars, lastStep: Number.isFinite(quota.lastStep) ? quota.lastStep : null },
|
|
644
|
+
silence: silence.reason,
|
|
645
|
+
}), audit)
|
|
646
|
+
|
|
647
|
+
return { ...decision, messages: [...decision.messages, injectionMessage(built.text)] }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* 会话级限流(S3):冷却步数 / 条目条数 / 条目字符。三个任一触顶 → 条目通道整体沉默,
|
|
652
|
+
* 但本轮"本该注入什么"仍会进 dropped(不做静默降级)。
|
|
653
|
+
*/
|
|
654
|
+
function sessionSilence({ cfg, quota, stepNo }) {
|
|
655
|
+
const cooling = Number.isFinite(quota.lastStep) && stepNo - quota.lastStep < cfg.gateCooldownSteps
|
|
656
|
+
const itemsLeft = Math.max(0, cfg.maxItemsPerSession - quota.items)
|
|
657
|
+
const charsLeft = Math.max(0, cfg.maxItemCharsPerSession - quota.chars)
|
|
658
|
+
const reason = cooling ? 'cooldown'
|
|
659
|
+
: itemsLeft === 0 ? 'session-items'
|
|
660
|
+
: charsLeft === 0 ? 'session-chars' : null
|
|
661
|
+
return { reason, itemsLeft, charsLeft }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 条目级去重判据:会话里注入过且正文未变 → 跳过。cooldown=0 永久有效,>0 走步数冷却。 */
|
|
665
|
+
function makeSkipItem(seen, stepNo, cooldown) {
|
|
666
|
+
return (it) => {
|
|
667
|
+
const rec = seen.get(it.id)
|
|
668
|
+
if (!rec || rec.hash !== insightItemHash(it)) return false
|
|
669
|
+
return cooldown === 0 || stepNo - rec.step < cooldown
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** 预算丢弃的留痕(cfg.budgetLog):once=每会话首次,all=丢弃组合每变一次。 */
|
|
674
|
+
function recordDropped({ sessions, sessionId, dropped, budgetLog }) {
|
|
675
|
+
if (!dropped || !dropped.length || budgetLog === 'off') return
|
|
676
|
+
const signature = dropped.map((d) => `${d.id}:${d.reason}`).join(',')
|
|
677
|
+
const previous = sessions.lastDropped.peek(sessionId)
|
|
678
|
+
if (previous === signature) return
|
|
679
|
+
sessions.lastDropped.set(sessionId, signature)
|
|
680
|
+
if (budgetLog === 'all' || previous === undefined) {
|
|
681
|
+
console.error(
|
|
682
|
+
`[dsh-project-memory] auto-inject degraded: ${dropped.length} insight(s) kept out by budget — `
|
|
683
|
+
+ dropped.map((d) => `${d.id}(${d.reason})`).join(', '),
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** 追加的注入消息:必须是带 source 的完整消息——裸 {role,content} 会让宿主读 message.source 时崩。 */
|
|
689
|
+
function injectionMessage(text) {
|
|
690
|
+
return createUserMessage({
|
|
691
|
+
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${text}` }],
|
|
692
|
+
// 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
|
|
693
|
+
// 宿主 ContextFormed 是判别联合:snapshot 必须带 sections(notice 才需要 summary)。
|
|
694
|
+
// kind 必须是生产者自有的(v4 没有 'plugin' 兜底 kind),见 SOURCE_KIND 的注释。
|
|
695
|
+
source: {
|
|
696
|
+
kind: SOURCE_KIND,
|
|
697
|
+
form: 'snapshot',
|
|
698
|
+
sections: [{ name: 'project-memory', text }],
|
|
699
|
+
},
|
|
539
700
|
})
|
|
540
701
|
}
|