@yolk_vat-y/dsh-project-memory 0.5.4 → 0.5.5
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 +105 -0
- package/README.md +75 -42
- package/README.zh-CN.md +75 -42
- package/client/client.js +111 -92
- package/client/client.js.map +1 -1
- package/package.json +5 -2
- package/scripts/bench-store.mjs +113 -0
- package/scripts/bench-synthetic.mjs +364 -0
- package/scripts/bench.mjs +396 -0
- package/src/auto-inject.js +246 -51
- package/src/client/MemoryView.tsx +4 -2
- package/src/client/TaskComponents.tsx +7 -6
- package/src/client/TaskPanel.module.css +7 -0
- package/src/client/TaskPanel.tsx +12 -8
- package/src/client/task-ui-store.ts +11 -1
- package/src/index.js +19 -2
- package/src/insight-store.js +6 -4
- package/src/parsers/pdfjs-parser.js +3 -0
- package/src/readiness.js +224 -0
- package/src/recall.js +217 -0
- package/src/store.js +20 -1
- package/src/tools/index-repo.js +7 -1
- package/src/tools/lesson-tools.js +7 -2
- package/src/tools/query-memory.js +61 -23
- package/src/tools/watch-repo.js +10 -1
- package/src/util/fs.js +41 -0
- package/src/watch.js +28 -4
package/src/auto-inject.js
CHANGED
|
@@ -13,9 +13,18 @@ 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, matchTrigger, relativeHits } from './readiness.js'
|
|
16
19
|
|
|
17
20
|
export const INJECT_MARK = '[Memory Inject]'
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* 注入正文的最小可用长度:预算塞不下这么多就宁可不注入(记 dropped)。
|
|
24
|
+
* 与其输出 `- [ins_xxx] procedure ` 这种 stub,不如保持沉默——stub 只消耗 token 不传递信息。
|
|
25
|
+
*/
|
|
26
|
+
const MIN_BODY_CHARS = { trigger: 48, hint: 120 }
|
|
27
|
+
|
|
19
28
|
/** 注入引擎配置:insight 默认之上叠加 autoContext 预算/门控参数。 */
|
|
20
29
|
export function cfgEngine(config) {
|
|
21
30
|
const c = (config && config.autoContext) || {}
|
|
@@ -24,11 +33,25 @@ export function cfgEngine(config) {
|
|
|
24
33
|
...base,
|
|
25
34
|
maxTokens: typeof c.maxTokens === 'number' ? c.maxTokens : 400,
|
|
26
35
|
entryMaxInsights: typeof c.entryMaxInsights === 'number' ? c.entryMaxInsights : 6,
|
|
27
|
-
|
|
36
|
+
// 提示通道的相对阈值(层内最高分的比例):尺度无关,取代旧的"绝对 overlap"常量。
|
|
37
|
+
// 旧判据 normalizedTokenOverlap = |交集| / max(|A|,|B|) 对长消息必然趋零(实测 0.014–0.057)。
|
|
38
|
+
signalMinRatio: typeof c.signalMinRatio === 'number' ? c.signalMinRatio : 0.5,
|
|
39
|
+
// 兼容闸门:显式配置 relevanceMin 时仍走旧的绝对 overlap 判据;不配置则为 null(走相对阈值)。
|
|
40
|
+
relevanceMin: typeof c.relevanceMin === 'number' ? c.relevanceMin : null,
|
|
28
41
|
// resident 任务卡最多显示几个"编辑中"文件(纯写权重,最近写优先)
|
|
29
42
|
editedMax: typeof c.editedMax === 'number' ? c.editedMax : 3,
|
|
30
43
|
// 模型自己写/维护任务清单后、尚无新人类消息时,不把任务卡再回声给模型(省 token)
|
|
31
44
|
skipEchoSelfTodo: c.skipEchoSelfTodo !== false,
|
|
45
|
+
// 预算审计日志级别(stderr)。默认 off:预算挤掉低优先级条目是正常降级,不是故障,
|
|
46
|
+
// 而终端是用户可见面——默认打印会让一次启动刷出多行,代价远大于那点可观测性收益。
|
|
47
|
+
// off : 永不打印(默认)
|
|
48
|
+
// once : 每个会话最多一行(首次出现预算丢弃时),够定位又不刷屏
|
|
49
|
+
// all : 丢弃组合每变化一次打一行(作者排查用)
|
|
50
|
+
budgetLog: ['off', 'once', 'all'].includes(c.budgetLog) ? c.budgetLog : 'off',
|
|
51
|
+
// 同一条 insight 在本会话里重复注入的冷却(单位:pre-step 步数)。
|
|
52
|
+
// 0(默认)= 内容没变就不再注入:注入消息会留在会话历史里(宿主不压缩历史),
|
|
53
|
+
// 整块重发同一条 1200 字 procedure 只是重复占位。>0 用于历史可能被外部裁剪的场景。
|
|
54
|
+
reinjectItemsAfter: typeof c.reinjectItemsAfter === 'number' && c.reinjectItemsAfter >= 0 ? c.reinjectItemsAfter : 0,
|
|
32
55
|
}
|
|
33
56
|
}
|
|
34
57
|
|
|
@@ -40,16 +63,20 @@ function textOf(message) {
|
|
|
40
63
|
.join('\n')
|
|
41
64
|
}
|
|
42
65
|
|
|
43
|
-
function lastUserText(messages) {
|
|
66
|
+
export function lastUserText(messages) {
|
|
44
67
|
if (!Array.isArray(messages)) return ''
|
|
68
|
+
let fallback = ''
|
|
45
69
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
46
70
|
const m = messages[i]
|
|
47
|
-
if (m
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
if (!m || m.role !== 'user') continue
|
|
72
|
+
const t = textOf(m)
|
|
73
|
+
if (!t.trim()) continue
|
|
74
|
+
// 真人消息优先:本插件注入的块也是 role=user,若按"最后一条 user"取,
|
|
75
|
+
// 上一步的注入块会变成这一步的就绪查询(自激:拿自己注入的内容再检索一遍)。
|
|
76
|
+
if (m.source && m.source.kind === 'user') return t.trim()
|
|
77
|
+
if (!fallback) fallback = t.trim() // 无 source 的消息(老宿主 / 测试)兜底
|
|
51
78
|
}
|
|
52
|
-
return
|
|
79
|
+
return fallback
|
|
53
80
|
}
|
|
54
81
|
|
|
55
82
|
function fingerprint(text) {
|
|
@@ -103,65 +130,144 @@ export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
|
|
|
103
130
|
return parts.join('\n')
|
|
104
131
|
}
|
|
105
132
|
|
|
133
|
+
/** 候选池:project + global + 绑定任务(同一 id 只取一次;归档/草稿不进注入)。 */
|
|
134
|
+
function insightCandidates(store, globalStore, task) {
|
|
135
|
+
const out = []
|
|
136
|
+
const seen = new Set()
|
|
137
|
+
const push = (it) => {
|
|
138
|
+
if (!it || it.archived || it.draft) return
|
|
139
|
+
const key = it.id || `${it.kind}:${it.title}`
|
|
140
|
+
if (seen.has(key)) return
|
|
141
|
+
seen.add(key)
|
|
142
|
+
out.push(it)
|
|
143
|
+
}
|
|
144
|
+
for (const it of (store ? store.insightItems() : []) || []) push(it)
|
|
145
|
+
for (const it of (globalStore ? globalStore.items() : []) || []) push(it)
|
|
146
|
+
for (const it of (task && task.insights) || []) push(it)
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 一条 insight 的注入正文:procedure 给全步骤,其余给标题 + 结论。 */
|
|
151
|
+
function insightBody(it) {
|
|
152
|
+
return it.kind === 'procedure' && Array.isArray(it.steps)
|
|
153
|
+
? `【${it.title}】\n${it.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
|
|
154
|
+
: `- [${it.id}] ${it.title}${it.fix || it.solution || it.choice ? ` — ${it.fix || it.solution || it.choice}` : ''}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 条目级去重键:注入正文的指纹(正文变了才允许再注入一次,见 cfg.reinjectItemsAfter)。 */
|
|
158
|
+
export function insightItemHash(it) {
|
|
159
|
+
return createHash('sha256').update(insightBody(it)).digest('hex').slice(0, 12)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 把正文塞进剩余预算:放不下就截断;连"有用前缀"都留不下就返回 null(由调用方记 dropped)。 */
|
|
163
|
+
export function fitBody(body, remaining, min = MIN_BODY_CHARS.hint) {
|
|
164
|
+
if (remaining <= min) return null
|
|
165
|
+
if (body.length <= remaining) return body
|
|
166
|
+
return `${body.slice(0, remaining - 1)}…`
|
|
167
|
+
}
|
|
168
|
+
|
|
106
169
|
/**
|
|
107
170
|
* 构建注入内容(不写盘、无副作用)。
|
|
108
|
-
*
|
|
109
|
-
*
|
|
171
|
+
*
|
|
172
|
+
* 交付契约:**authored trigger(所有 kind)= 确定性全文注入,优先级 1;统计信号 = 截断提示,
|
|
173
|
+
* 优先级 2**。预算排程为:常驻任务卡 → trigger 命中 → 提示。每一步都记 `reasons`(为什么注入)
|
|
174
|
+
* 与 `dropped`(为什么没注入),让"注入/未注入"可审计——而不是只有静默的门槛。
|
|
175
|
+
* @param {object} opts { query, readiness, task, store, globalStore, projectTagsList, cfg, skipItem? }
|
|
176
|
+
* @param {(it: object) => boolean} [opts.skipItem] 条目级去重:返回 true 的条目直接退出排程
|
|
177
|
+
* (本会话已注入过且正文未变)。在排程前过滤,省下的预算留给新条目。
|
|
178
|
+
* @returns {{ text: string, labels: string[], reasons: object[], dropped: object[] }}
|
|
110
179
|
*/
|
|
111
180
|
export function buildInjection(opts) {
|
|
112
181
|
const { query, task, globalStore, cfg } = opts
|
|
113
182
|
const store = opts.store || null
|
|
114
183
|
const tags = opts.projectTagsList || []
|
|
184
|
+
const ctx = buildReadinessContext(opts.readiness || { humanText: query || '' })
|
|
115
185
|
const labels = []
|
|
186
|
+
const reasons = []
|
|
187
|
+
const dropped = []
|
|
116
188
|
const parts = []
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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]
|
|
189
|
+
const budgetChars = (cfg.maxTokens || 400) * 3
|
|
190
|
+
|
|
191
|
+
const skipItem = typeof opts.skipItem === 'function' ? opts.skipItem : null
|
|
192
|
+
const cands = insightCandidates(store, globalStore, task).filter((it) => !(skipItem && skipItem(it)))
|
|
193
|
+
|
|
194
|
+
// 通道 1:authored trigger —— 确定性、全文、优先级 1。procedure 先于其它 kind(步骤更完整)。
|
|
195
|
+
const triggered = []
|
|
135
196
|
for (const it of cands) {
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
197
|
+
const why = matchTrigger(it.trigger, ctx)
|
|
198
|
+
if (!why) continue
|
|
199
|
+
const scope = it.trigger?.scope
|
|
200
|
+
if (Array.isArray(scope) && scope.length && tags.length && !scope.some((s) => tags.includes(s))) continue
|
|
201
|
+
triggered.push({ it, why })
|
|
140
202
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
203
|
+
triggered.sort(
|
|
204
|
+
(a, b) => (a.it.kind === 'procedure' ? 0 : 1) - (b.it.kind === 'procedure' ? 0 : 1)
|
|
205
|
+
|| String(a.it.id).localeCompare(String(b.it.id)),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
// 通道 2:统计信号 —— 提示态、优先级 2,判据是层内相对阈值(尺度无关)。
|
|
209
|
+
// procedure 只走 trigger 通道:否则会被统计通道绕过 trigger.scope 的画像过滤。
|
|
210
|
+
const consumed = new Set(triggered.map((t) => t.it.id))
|
|
211
|
+
const hintCands = cands.filter((it) => !consumed.has(it.id) && it.kind !== 'procedure')
|
|
212
|
+
const hints = []
|
|
213
|
+
if (hintCands.length) {
|
|
214
|
+
if (typeof cfg.relevanceMin === 'number') {
|
|
215
|
+
// 兼容:显式 relevanceMin → 旧的绝对 overlap 判据(老 profile 行为不变)
|
|
216
|
+
for (const it of hintCands) {
|
|
217
|
+
const score = normalizedTokenOverlap(ctx.humanText || query || '', insightMatchText(it))
|
|
218
|
+
if (score >= cfg.relevanceMin) hints.push({ it, score, why: `overlap:${score.toFixed(3)}` })
|
|
219
|
+
}
|
|
220
|
+
hints.sort((a, b) => b.score - a.score)
|
|
221
|
+
} else {
|
|
222
|
+
const byId = new Map(hintCands.map((it) => [it.id, it]))
|
|
223
|
+
// 提示查询先剔除 1–2 字符的拉丁缩写(PR/CI/OS):它们一个巧合命中就能当上该层最高分。
|
|
224
|
+
const scored = rankEntriesMergedScored(
|
|
225
|
+
hintCands.map(insightToEntry),
|
|
226
|
+
[hintQueryText(ctx.humanText), hintQueryText(ctx.actionText)].filter(Boolean),
|
|
227
|
+
hintCands.length,
|
|
228
|
+
)
|
|
229
|
+
for (const r of relativeHits(scored, { ratioMin: cfg.signalMinRatio })) {
|
|
230
|
+
const it = byId.get(r.entry.insightId)
|
|
231
|
+
if (it) hints.push({ it, score: r.score, why: `relative:${(r.score / (scored[0].score || 1)).toFixed(2)}` })
|
|
232
|
+
}
|
|
233
|
+
}
|
|
154
234
|
}
|
|
235
|
+
|
|
155
236
|
// 常驻块文本。去重指纹只看稳定内容(任务标题/步骤/insights + 相关 insights):
|
|
156
237
|
// “编辑中”随每次写文件变化,若参与指纹会导致每写一个文件就重发整块(噪音 + token 浪费)。
|
|
157
238
|
const echo = shouldEchoTaskCard(task, cfg)
|
|
158
239
|
const entry = echo ? buildEntryContent(task, cfg) : ''
|
|
159
240
|
const entryStable = echo ? buildEntryContent(task, cfg, { withEdited: false }) : ''
|
|
241
|
+
let used = entry.length
|
|
242
|
+
|
|
243
|
+
for (const { it, why } of triggered) {
|
|
244
|
+
const body = fitBody(insightBody(it), budgetChars - used, MIN_BODY_CHARS.trigger)
|
|
245
|
+
if (body === null) {
|
|
246
|
+
dropped.push({ id: it.id, channel: 'trigger', reason: 'budget' })
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
used += body.length + 1
|
|
250
|
+
parts.push(body)
|
|
251
|
+
labels.push(it.kind === 'procedure' ? 'procedure' : it.kind)
|
|
252
|
+
reasons.push({ id: it.id, channel: 'trigger', why, hash: insightItemHash(it) })
|
|
253
|
+
}
|
|
254
|
+
for (const { it, why } of hints) {
|
|
255
|
+
const body = fitBody(insightBody(it), budgetChars - used, MIN_BODY_CHARS.hint)
|
|
256
|
+
if (body === null) {
|
|
257
|
+
dropped.push({ id: it.id, channel: 'hint', reason: 'budget' })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
used += body.length + 1
|
|
261
|
+
parts.push(body)
|
|
262
|
+
labels.push('hint')
|
|
263
|
+
reasons.push({ id: it.id, channel: 'hint', why, hash: insightItemHash(it) })
|
|
264
|
+
}
|
|
265
|
+
|
|
160
266
|
const total = [entry, ...parts].filter(Boolean)
|
|
161
|
-
if (!total.length) return { text: '', labels
|
|
162
|
-
const clamp = (t) => (t.length > budgetChars ? t.slice(0, budgetChars)
|
|
267
|
+
if (!total.length) return { text: '', labels, reasons, dropped }
|
|
268
|
+
const clamp = (t) => (t.length > budgetChars ? `${t.slice(0, budgetChars)}\n…(截断)` : t)
|
|
163
269
|
const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
|
|
164
|
-
return { text: clamp(total.join('\n')), labels, dedupeText }
|
|
270
|
+
return { text: clamp(total.join('\n')), labels, reasons, dropped, dedupeText }
|
|
165
271
|
}
|
|
166
272
|
|
|
167
273
|
/** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。
|
|
@@ -175,6 +281,45 @@ export function installAutoInject(ctx, config) {
|
|
|
175
281
|
// 每个会话一份“上次注入指纹”。用单个变量会让并发会话互相抑制注入。
|
|
176
282
|
const lastFpBySession = new Map()
|
|
177
283
|
const LAST_FP_MAX = 200
|
|
284
|
+
// 每个会话一份"上次预算丢弃指纹":预算把条目挤出去时必须留痕一次,而不是静默(degraded 可见性)。
|
|
285
|
+
const lastDroppedBySession = new Map()
|
|
286
|
+
// 条目级去重记忆:sessionId → Map(insightId → { hash, step })。注入的消息留在会话历史里
|
|
287
|
+
// (宿主只追加、不压缩),所以"整块指纹变了"不等于"内容都是新的"——同一份 procedure 会因为
|
|
288
|
+
// 滑动工具窗口、任务卡更新、预算截断边界变化被整块重发(实测 66 步注入 20 次,其中同一份
|
|
289
|
+
// 1732 字 procedure 重发 3 次、另一份 1008 字的 6 次)。这里按条目记账,正文没变就不再排程。
|
|
290
|
+
const injectedBySession = new Map()
|
|
291
|
+
const INJECTED_ITEMS_MAX = 600
|
|
292
|
+
// 会话步数:为 reinjectItemsAfter 提供时间轴(>0 时才用得上)。
|
|
293
|
+
const stepBySession = new Map()
|
|
294
|
+
// 反应窗口:本会话最近观察到的 tool/call(参数里有 git commit / npm publish / 改动的路径)。
|
|
295
|
+
// 宿主没有"工具执行前拦截"钩子,所以这是 pre-step 之外唯一能拿到的动作事实。
|
|
296
|
+
const observedBySession = new Map()
|
|
297
|
+
const OBSERVED_MAX = 8
|
|
298
|
+
const OBSERVED_SESSIONS = 200
|
|
299
|
+
ctx.on('session/event', (session, event) => {
|
|
300
|
+
try {
|
|
301
|
+
if (!event || event.type !== 'tool/call') return
|
|
302
|
+
const sid = session && session.id
|
|
303
|
+
if (!sid) return
|
|
304
|
+
const data = event.data || {}
|
|
305
|
+
const rec = observedBySession.get(sid) || []
|
|
306
|
+
rec.push({ name: String(data.name || ''), arguments: String(data.arguments || '') })
|
|
307
|
+
while (rec.length > OBSERVED_MAX) rec.shift()
|
|
308
|
+
observedBySession.set(sid, rec)
|
|
309
|
+
while (observedBySession.size > OBSERVED_SESSIONS) {
|
|
310
|
+
observedBySession.delete(observedBySession.keys().next().value)
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// 观察失败绝不影响宿主请求
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
if (typeof ctx.effect === 'function') {
|
|
317
|
+
ctx.effect(() => () => {
|
|
318
|
+
observedBySession.clear()
|
|
319
|
+
injectedBySession.clear()
|
|
320
|
+
stepBySession.clear()
|
|
321
|
+
})
|
|
322
|
+
}
|
|
178
323
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
179
324
|
// 宿主契约是 waterfall(payload, next),next 一定存在;但一旦宿主版本漂移、或事件被当
|
|
180
325
|
// 普通事件调用,next 缺失会让本监听器 reject(历史事故正是 `next is not a function`)。
|
|
@@ -195,14 +340,45 @@ export function installAutoInject(ctx, config) {
|
|
|
195
340
|
const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
196
341
|
const boundTaskId = store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
|
|
197
342
|
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
198
|
-
|
|
343
|
+
// 两个窗口合并进同一个就绪上下文:人类消息(先发)+ 已观察动作(反应)。
|
|
344
|
+
const observed = observedBySession.get(sessionId) || []
|
|
345
|
+
const readiness = buildReadinessContext({
|
|
346
|
+
humanText: query || '',
|
|
347
|
+
actionText: observed.map((c) => `${c.name} ${c.arguments}`).join('\n'),
|
|
348
|
+
})
|
|
349
|
+
// 条目级去重:本会话已注入过、且正文未变的条目不再参与排程(cfg.reinjectItemsAfter=0 时永久,
|
|
350
|
+
// >0 时走冷却步数,用于历史可能被外部裁剪的场景)。正文变了(编辑过 insight)立刻允许重发。
|
|
351
|
+
const stepNo = (stepBySession.get(sessionId) || 0) + 1
|
|
352
|
+
stepBySession.set(sessionId, stepNo)
|
|
353
|
+
while (stepBySession.size > LAST_FP_MAX) stepBySession.delete(stepBySession.keys().next().value)
|
|
354
|
+
const seen = injectedBySession.get(sessionId) || new Map()
|
|
355
|
+
injectedBySession.set(sessionId, seen)
|
|
356
|
+
while (injectedBySession.size > LAST_FP_MAX) injectedBySession.delete(injectedBySession.keys().next().value)
|
|
357
|
+
const cooldown = cfg.reinjectItemsAfter || 0
|
|
358
|
+
const skipItem = cooldown === 0
|
|
359
|
+
? (it) => {
|
|
360
|
+
const rec = seen.get(it.id)
|
|
361
|
+
return Boolean(rec) && rec.hash === insightItemHash(it)
|
|
362
|
+
}
|
|
363
|
+
: (it) => {
|
|
364
|
+
const rec = seen.get(it.id)
|
|
365
|
+
return Boolean(rec) && rec.hash === insightItemHash(it) && stepNo - rec.step < cooldown
|
|
366
|
+
}
|
|
367
|
+
const built = buildInjection({ query: query || '', readiness, task, store, globalStore, projectTagsList: projectTags(root), cfg, skipItem })
|
|
199
368
|
const fp = fingerprint(built.dedupeText ?? built.text)
|
|
200
|
-
|
|
369
|
+
const shouldInject = Boolean(built.text) && fp !== lastFpBySession.get(sessionId)
|
|
370
|
+
let injectMessage = null
|
|
371
|
+
if (shouldInject) {
|
|
201
372
|
// 以宿主 createUserMessage 构造的完整 user 消息追加(带 id/source,plan-mode narration 同款)。
|
|
202
373
|
// 裸 {role,content} 消息缺 source 会让宿主逐条读 message.source.kind 时崩溃。
|
|
203
374
|
lastFpBySession.set(sessionId, fp)
|
|
204
375
|
if (lastFpBySession.size > LAST_FP_MAX) lastFpBySession.delete(lastFpBySession.keys().next().value)
|
|
205
|
-
|
|
376
|
+
// 只记真的进了上下文的那几条:dropped 的没被看到,不能记账(否则以后永远不再注入)。
|
|
377
|
+
for (const r of built.reasons) {
|
|
378
|
+
if (r && r.id && r.hash) seen.set(r.id, { hash: r.hash, step: stepNo })
|
|
379
|
+
}
|
|
380
|
+
while (seen.size > INJECTED_ITEMS_MAX) seen.delete(seen.keys().next().value)
|
|
381
|
+
injectMessage = createUserMessage({
|
|
206
382
|
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` }],
|
|
207
383
|
// 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
|
|
208
384
|
// 宿主 ContextFormed 是判别联合:snapshot 必须带 sections(notice 才需要 summary)。
|
|
@@ -214,8 +390,27 @@ export function installAutoInject(ctx, config) {
|
|
|
214
390
|
sections: [{ name: 'project-memory', text: built.text }],
|
|
215
391
|
},
|
|
216
392
|
})
|
|
217
|
-
return { ...decision, messages: [...decision.messages, injectMessage] }
|
|
218
393
|
}
|
|
394
|
+
// 未注入 ≠ 无事发生:因预算被挤掉的条目按 cfg.budgetLog 留痕(默认 off,见 cfgEngine)。
|
|
395
|
+
// 留痕仍然记账(去重 + 上限),只是默认不外泄到用户的终端。
|
|
396
|
+
if (built.dropped && built.dropped.length && cfg.budgetLog !== 'off') {
|
|
397
|
+
const sig = built.dropped.map((d) => `${d.id}:${d.reason}`).join(',')
|
|
398
|
+
if (lastDroppedBySession.get(sessionId) !== sig) {
|
|
399
|
+
const seenBefore = lastDroppedBySession.has(sessionId)
|
|
400
|
+
lastDroppedBySession.set(sessionId, sig)
|
|
401
|
+
while (lastDroppedBySession.size > LAST_FP_MAX) {
|
|
402
|
+
lastDroppedBySession.delete(lastDroppedBySession.keys().next().value)
|
|
403
|
+
}
|
|
404
|
+
// once:只有本会话第一次丢弃出声,之后继续记账但保持安静。
|
|
405
|
+
if (cfg.budgetLog === 'all' || !seenBefore) {
|
|
406
|
+
console.error(
|
|
407
|
+
`[dsh-project-memory] auto-inject degraded: ${built.dropped.length} insight(s) kept out by budget — `
|
|
408
|
+
+ built.dropped.map((d) => `${d.id}(${d.reason})`).join(', '),
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (injectMessage) return { ...decision, messages: [...decision.messages, injectMessage] }
|
|
219
414
|
} catch (err) {
|
|
220
415
|
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
221
416
|
}
|
|
@@ -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
|
)}
|
package/src/client/TaskPanel.tsx
CHANGED
|
@@ -75,7 +75,8 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
75
75
|
const [syncing, setSyncing] = useState(false)
|
|
76
76
|
const [syncedAt, setSyncedAt] = useState(0)
|
|
77
77
|
const [syncError, setSyncError] = useState<string | null>(null)
|
|
78
|
-
|
|
78
|
+
// 提示信息开关落在 UI store(持久化):关掉后面板内所有悬停 title 气泡都不再弹出。
|
|
79
|
+
const showHints = ui.showHints
|
|
79
80
|
const [view, setView] = useState<PanelView>('task')
|
|
80
81
|
const cycleView = () => {
|
|
81
82
|
const i = VIEW_CYCLE.indexOf(view)
|
|
@@ -288,6 +289,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
288
289
|
<MiniBar
|
|
289
290
|
label={label}
|
|
290
291
|
hint={t('minibar.click-expand')}
|
|
292
|
+
showHints={showHints}
|
|
291
293
|
position={ui.panelPosition}
|
|
292
294
|
theme={style}
|
|
293
295
|
onMove={uiActions.setPanelPosition}
|
|
@@ -308,7 +310,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
308
310
|
style={{ left: ui.panelPosition.x, top: ui.panelPosition.y }}
|
|
309
311
|
data-theme={style === 'native' ? undefined : style}
|
|
310
312
|
>
|
|
311
|
-
<div className={css.dragHandle} onMouseDown={handleDragStart} title={t('task.drag')}>
|
|
313
|
+
<div className={css.dragHandle} onMouseDown={handleDragStart} title={showHints ? t('task.drag') : undefined}>
|
|
312
314
|
<div className={css.handleGrip} />
|
|
313
315
|
</div>
|
|
314
316
|
<header className={css.header}>
|
|
@@ -317,7 +319,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
317
319
|
type="button"
|
|
318
320
|
className={css.headerIconBtn}
|
|
319
321
|
onClick={cycleTheme}
|
|
320
|
-
title={styleLabel}
|
|
322
|
+
title={showHints ? styleLabel : undefined}
|
|
321
323
|
aria-label={styleLabel}
|
|
322
324
|
>
|
|
323
325
|
<IconFolderOpenOutline16 className={css.headerIcon} />
|
|
@@ -330,7 +332,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
330
332
|
size="sm"
|
|
331
333
|
onClick={cycleView}
|
|
332
334
|
aria-label={t('view.cycle')}
|
|
333
|
-
title={`${t('view.cycle')}(${view} → ${VIEW_CYCLE[(VIEW_CYCLE.indexOf(view) + 1) % VIEW_CYCLE.length]})`}
|
|
335
|
+
title={showHints ? `${t('view.cycle')}(${view} → ${VIEW_CYCLE[(VIEW_CYCLE.indexOf(view) + 1) % VIEW_CYCLE.length]})` : undefined}
|
|
334
336
|
>
|
|
335
337
|
{/* lucide notebook-tabs(视图循环图标) */}
|
|
336
338
|
<svg
|
|
@@ -365,7 +367,9 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
365
367
|
<Button
|
|
366
368
|
variant="ghost"
|
|
367
369
|
size="sm"
|
|
368
|
-
|
|
370
|
+
className={showHints ? undefined : css.hintsOff}
|
|
371
|
+
onClick={() => uiActions.toggleHints()}
|
|
372
|
+
aria-pressed={!showHints}
|
|
369
373
|
aria-label={showHints ? t('panel.hints-off') : t('panel.hints-on')}
|
|
370
374
|
title={showHints ? t('panel.hints-off') : t('panel.hints-on')}
|
|
371
375
|
>
|
|
@@ -376,7 +380,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
376
380
|
size="sm"
|
|
377
381
|
onClick={() => uiActions.minimize()}
|
|
378
382
|
aria-label={t('panel.minimize')}
|
|
379
|
-
title={t('panel.minimize')}
|
|
383
|
+
title={showHints ? t('panel.minimize') : undefined}
|
|
380
384
|
>
|
|
381
385
|
<IconChevronDownOutline14 />
|
|
382
386
|
</Button>
|
|
@@ -385,7 +389,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
385
389
|
size="sm"
|
|
386
390
|
onClick={() => uiActions.close()}
|
|
387
391
|
aria-label={t('panel.close')}
|
|
388
|
-
title={t('panel.close')}
|
|
392
|
+
title={showHints ? t('panel.close') : undefined}
|
|
389
393
|
>
|
|
390
394
|
<IconCloseOutline16 />
|
|
391
395
|
</Button>
|
|
@@ -396,7 +400,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
396
400
|
{syncError && <div className={css.notice}>{t('panel.sync-failed')}: {syncError}</div>}
|
|
397
401
|
|
|
398
402
|
{view !== 'task' ? (
|
|
399
|
-
<MemoryView ctx={ctx} sessionId={sessionId} scope={view as InsightScope} boundTaskId={data.boundTaskId} t={t} />
|
|
403
|
+
<MemoryView ctx={ctx} sessionId={sessionId} scope={view as InsightScope} boundTaskId={data.boundTaskId} showHints={showHints} t={t} />
|
|
400
404
|
) : activeTasks.length === 0 ? (
|
|
401
405
|
<div className={css.emptyState}>
|
|
402
406
|
<p className={css.emptyTitle}>{t('panel.empty')}</p>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Task UI Store — 仅管本地 UI 状态(closed、minimized、position、expandedIds、theme)
|
|
2
|
+
* Task UI Store — 仅管本地 UI 状态(closed、minimized、position、expandedIds、theme、showHints)
|
|
3
3
|
* - 持久化到 localStorage(key: dsh-pm-task-panel-ui)
|
|
4
4
|
* - 不跨标签页同步(每个标签页独立)
|
|
5
5
|
* - 启动/刷新强制 closed: true,面板默认不显示
|
|
@@ -14,6 +14,8 @@ interface TaskUIState {
|
|
|
14
14
|
minimized: boolean
|
|
15
15
|
closed: boolean
|
|
16
16
|
theme: string
|
|
17
|
+
/** 是否显示悬停提示信息(title 气泡);false 时面板内所有功能提示都不弹出。 */
|
|
18
|
+
showHints: boolean
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
function defaultPosition(): { x: number; y: number } {
|
|
@@ -40,6 +42,7 @@ function getDefaultUIState(): TaskUIState {
|
|
|
40
42
|
minimized: parsed.minimized !== false,
|
|
41
43
|
closed: true,
|
|
42
44
|
theme: typeof parsed.theme === 'string' ? parsed.theme : 'native',
|
|
45
|
+
showHints: parsed.showHints !== false,
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
} catch { /* corrupt state — fall through to defaults */ }
|
|
@@ -50,6 +53,7 @@ function getDefaultUIState(): TaskUIState {
|
|
|
50
53
|
minimized: true,
|
|
51
54
|
closed: true,
|
|
52
55
|
theme: 'native',
|
|
56
|
+
showHints: true,
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -103,6 +107,12 @@ export const taskUIStore = {
|
|
|
103
107
|
setTheme(theme: string): void {
|
|
104
108
|
setUIState({ ...uiState, theme })
|
|
105
109
|
},
|
|
110
|
+
toggleHints(): void {
|
|
111
|
+
setUIState({ ...uiState, showHints: !uiState.showHints })
|
|
112
|
+
},
|
|
113
|
+
setShowHints(showHints: boolean): void {
|
|
114
|
+
setUIState({ ...uiState, showHints })
|
|
115
|
+
},
|
|
106
116
|
reset(): void {
|
|
107
117
|
setUIState(getDefaultUIState())
|
|
108
118
|
},
|
package/src/index.js
CHANGED
|
@@ -49,7 +49,7 @@ export const Config = Schema.object({
|
|
|
49
49
|
// 任务成为会话绑定(select_task / /task switch)时,把任务步骤推成宿主 todo/write 快照
|
|
50
50
|
syncHostOnAdopt: Schema.boolean().default(true),
|
|
51
51
|
}).default({}),
|
|
52
|
-
// v0.5 单一 insight
|
|
52
|
+
// v0.5 单一 insight 实体:分层去重/强化/提升/容量/归档
|
|
53
53
|
insight: Schema.object({
|
|
54
54
|
dedupOverlap: Schema.number().default(0.7),
|
|
55
55
|
reinforceBand: Schema.number().default(0.65),
|
|
@@ -72,7 +72,24 @@ export const Config = Schema.object({
|
|
|
72
72
|
enabled: Schema.boolean().default(true),
|
|
73
73
|
entryOn: Schema.boolean().default(true),
|
|
74
74
|
maxTokens: Schema.number().default(400),
|
|
75
|
-
|
|
75
|
+
entryMaxInsights: Schema.number().default(6),
|
|
76
|
+
// 提示通道的相对阈值(层内最高分的比例)——出厂判据
|
|
77
|
+
signalMinRatio: Schema.number().default(0.5),
|
|
78
|
+
// resident 任务卡最多显示几个「编辑中」文件
|
|
79
|
+
editedMax: Schema.number().default(3),
|
|
80
|
+
// 模型自己维护清单且之后没有新人类消息时,不把任务卡回声给模型
|
|
81
|
+
skipEchoSelfTodo: Schema.boolean().default(true),
|
|
82
|
+
// ⚠️ 旧兼容闸门,**刻意不给默认值**:cfgEngine 以「relevanceMin 是否为 number」选分支,
|
|
83
|
+
// 一旦有默认值就会永远走旧的绝对 overlap 判据——而它对长消息实测只有 0.014~0.057,
|
|
84
|
+
// 必然过不了,相对阈值 signalMinRatio 就变成死代码。只有用户显式配置才保留旧行为。
|
|
85
|
+
relevanceMin: Schema.number(),
|
|
86
|
+
// 预算审计日志(stderr)**默认 off**:终端是用户可见面,而"预算挤掉低优先级条目"是
|
|
87
|
+
// 正常降级、不是故障——默认打印会让一次 dsh web 启动刷出多行,用户的第一反应是卸载插件。
|
|
88
|
+
// off(默认,永不打印)/ once(每个会话最多一行,首次出现丢弃时)/ all(丢弃组合每变化一次一行,作者排查)
|
|
89
|
+
budgetLog: Schema.union(['off', 'once', 'all']).default('off'),
|
|
90
|
+
// 同一条 insight 在本会话里重复注入的冷却(pre-step 步数)。0(默认)= 正文没变就不再注入:
|
|
91
|
+
// 注入消息留在会话历史里(宿主只追加不压缩),整块重发只是重复占位。>0 用于外部裁剪历史的场景。
|
|
92
|
+
reinjectItemsAfter: Schema.number().default(0),
|
|
76
93
|
}).default({}),
|
|
77
94
|
})
|
|
78
95
|
|