@yolk_vat-y/dsh-project-memory 0.5.5 → 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.
- package/CHANGELOG.md +25 -2
- package/README.md +27 -4
- package/README.zh-CN.md +26 -3
- package/package.json +4 -2
- package/src/audit.js +87 -0
- package/src/auto-inject.js +148 -27
- package/src/insight-store.js +21 -1
- package/src/ops.js +210 -0
- package/src/readiness.js +279 -35
- package/src/tools/lesson-tools.js +29 -7
package/src/auto-inject.js
CHANGED
|
@@ -15,7 +15,9 @@ import { cfgInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
|
|
|
15
15
|
import { projectTags } from './project-profile.js'
|
|
16
16
|
import { rankEntriesMergedScored } from './util/search.js'
|
|
17
17
|
import { insightToEntry } from './recall.js'
|
|
18
|
-
import { buildReadinessContext, hintQueryText, matchTrigger, relativeHits } from './readiness.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'
|
|
19
21
|
|
|
20
22
|
export const INJECT_MARK = '[Memory Inject]'
|
|
21
23
|
|
|
@@ -52,6 +54,25 @@ export function cfgEngine(config) {
|
|
|
52
54
|
// 0(默认)= 内容没变就不再注入:注入消息会留在会话历史里(宿主不压缩历史),
|
|
53
55
|
// 整块重发同一条 1200 字 procedure 只是重复占位。>0 用于历史可能被外部裁剪的场景。
|
|
54
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,
|
|
55
76
|
}
|
|
56
77
|
}
|
|
57
78
|
|
|
@@ -130,8 +151,9 @@ export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
|
|
|
130
151
|
return parts.join('\n')
|
|
131
152
|
}
|
|
132
153
|
|
|
133
|
-
/** 候选池:project + global + 绑定任务(同一 id 只取一次;归档/草稿不进注入)。
|
|
134
|
-
|
|
154
|
+
/** 候选池:project + global + 绑定任务(同一 id 只取一次;归档/草稿不进注入)。
|
|
155
|
+
* 候选在进入匹配前统一走旧 trigger → 新 schema 的归一(幂等,纯内存,不落盘)。 */
|
|
156
|
+
function insightCandidates(store, globalStore, task, opts) {
|
|
135
157
|
const out = []
|
|
136
158
|
const seen = new Set()
|
|
137
159
|
const push = (it) => {
|
|
@@ -139,7 +161,7 @@ function insightCandidates(store, globalStore, task) {
|
|
|
139
161
|
const key = it.id || `${it.kind}:${it.title}`
|
|
140
162
|
if (seen.has(key)) return
|
|
141
163
|
seen.add(key)
|
|
142
|
-
out.push(it)
|
|
164
|
+
out.push(normalizeTrigger(it, { legacyScope: opts?.legacyScope }))
|
|
143
165
|
}
|
|
144
166
|
for (const it of (store ? store.insightItems() : []) || []) push(it)
|
|
145
167
|
for (const it of (globalStore ? globalStore.items() : []) || []) push(it)
|
|
@@ -181,34 +203,44 @@ export function buildInjection(opts) {
|
|
|
181
203
|
const { query, task, globalStore, cfg } = opts
|
|
182
204
|
const store = opts.store || null
|
|
183
205
|
const tags = opts.projectTagsList || []
|
|
184
|
-
const ctx = buildReadinessContext(opts.readiness || { humanText: query || '' })
|
|
206
|
+
const ctx = buildReadinessContext({ ...(opts.readiness || { humanText: query || '' }), tags })
|
|
185
207
|
const labels = []
|
|
186
208
|
const reasons = []
|
|
187
209
|
const dropped = []
|
|
188
210
|
const parts = []
|
|
189
|
-
|
|
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))
|
|
190
216
|
|
|
191
217
|
const skipItem = typeof opts.skipItem === 'function' ? opts.skipItem : null
|
|
192
|
-
const cands = insightCandidates(store, globalStore, task).filter((it) => !(skipItem && skipItem(it)))
|
|
218
|
+
const cands = insightCandidates(store, globalStore, task, cfg).filter((it) => !(skipItem && skipItem(it)))
|
|
193
219
|
|
|
194
220
|
// 通道 1:authored trigger —— 确定性、全文、优先级 1。procedure 先于其它 kind(步骤更完整)。
|
|
221
|
+
// 准入化(S2):只有 `when`(op / 写目标 / 意图词)能触发,guard 只能收窄;
|
|
222
|
+
// **没有 `when` 的条目在这里永远不命中**(降级为可发现 + 按需拉取)。
|
|
195
223
|
const triggered = []
|
|
196
224
|
for (const it of cands) {
|
|
197
225
|
const why = matchTrigger(it.trigger, ctx)
|
|
198
226
|
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
227
|
triggered.push({ it, why })
|
|
202
228
|
}
|
|
203
229
|
triggered.sort(
|
|
204
230
|
(a, b) => (a.it.kind === 'procedure' ? 0 : 1) - (b.it.kind === 'procedure' ? 0 : 1)
|
|
205
231
|
|| String(a.it.id).localeCompare(String(b.it.id)),
|
|
206
232
|
)
|
|
233
|
+
if (skipItems) {
|
|
234
|
+
// 会话级限流命中:条目通道本轮整体沉默,但把"本该注入什么"记进 dropped——不做静默降级。
|
|
235
|
+
for (const t of triggered) dropped.push({ id: t.it.id, channel: 'trigger', reason: opts.silenceReason || 'cooldown' })
|
|
236
|
+
}
|
|
207
237
|
|
|
208
|
-
// 通道 2:统计信号 —— 提示态、优先级 2
|
|
209
|
-
//
|
|
238
|
+
// 通道 2:统计信号 —— 提示态、优先级 2。**双门槛**:层内相对阈值 + IDF 加权覆盖率(绝对下限)。
|
|
239
|
+
// 查询只用「人类消息的意图文字 + 本次写目标」:原始工具参数不再进查询,它们正是
|
|
240
|
+
// `dcterms→rm`、`*.pptx→论文笔记` 那类假阳性的来源。procedure 不进本通道(要过 trigger.scope)。
|
|
210
241
|
const consumed = new Set(triggered.map((t) => t.it.id))
|
|
211
|
-
const hintCands = cands.filter((it) => !consumed.has(it.id) && it.kind !== 'procedure')
|
|
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(' ')
|
|
212
244
|
const hints = []
|
|
213
245
|
if (hintCands.length) {
|
|
214
246
|
if (typeof cfg.relevanceMin === 'number') {
|
|
@@ -218,17 +250,37 @@ export function buildInjection(opts) {
|
|
|
218
250
|
if (score >= cfg.relevanceMin) hints.push({ it, score, why: `overlap:${score.toFixed(3)}` })
|
|
219
251
|
}
|
|
220
252
|
hints.sort((a, b) => b.score - a.score)
|
|
221
|
-
} else {
|
|
253
|
+
} else if (hintQueryText(hintQuery)) {
|
|
222
254
|
const byId = new Map(hintCands.map((it) => [it.id, it]))
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
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
|
|
229
265
|
for (const r of relativeHits(scored, { ratioMin: cfg.signalMinRatio })) {
|
|
230
266
|
const it = byId.get(r.entry.insightId)
|
|
231
|
-
if (it)
|
|
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}` })
|
|
232
284
|
}
|
|
233
285
|
}
|
|
234
286
|
}
|
|
@@ -239,35 +291,48 @@ export function buildInjection(opts) {
|
|
|
239
291
|
const entry = echo ? buildEntryContent(task, cfg) : ''
|
|
240
292
|
const entryStable = echo ? buildEntryContent(task, cfg, { withEdited: false }) : ''
|
|
241
293
|
let used = entry.length
|
|
294
|
+
let itemCount = 0
|
|
242
295
|
|
|
243
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
|
+
}
|
|
244
302
|
const body = fitBody(insightBody(it), budgetChars - used, MIN_BODY_CHARS.trigger)
|
|
245
303
|
if (body === null) {
|
|
246
304
|
dropped.push({ id: it.id, channel: 'trigger', reason: 'budget' })
|
|
247
305
|
continue
|
|
248
306
|
}
|
|
249
307
|
used += body.length + 1
|
|
308
|
+
itemCount++
|
|
250
309
|
parts.push(body)
|
|
251
310
|
labels.push(it.kind === 'procedure' ? 'procedure' : it.kind)
|
|
252
|
-
reasons.push({ id: it.id, channel: 'trigger', why, hash: insightItemHash(it) })
|
|
311
|
+
reasons.push({ id: it.id, channel: 'trigger', why, hash: insightItemHash(it), chars: body.length })
|
|
253
312
|
}
|
|
254
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
|
+
}
|
|
255
319
|
const body = fitBody(insightBody(it), budgetChars - used, MIN_BODY_CHARS.hint)
|
|
256
320
|
if (body === null) {
|
|
257
321
|
dropped.push({ id: it.id, channel: 'hint', reason: 'budget' })
|
|
258
322
|
continue
|
|
259
323
|
}
|
|
260
324
|
used += body.length + 1
|
|
325
|
+
itemCount++
|
|
261
326
|
parts.push(body)
|
|
262
327
|
labels.push('hint')
|
|
263
|
-
reasons.push({ id: it.id, channel: 'hint', why, hash: insightItemHash(it) })
|
|
328
|
+
reasons.push({ id: it.id, channel: 'hint', why, hash: insightItemHash(it), chars: body.length })
|
|
264
329
|
}
|
|
265
330
|
|
|
266
331
|
const total = [entry, ...parts].filter(Boolean)
|
|
267
|
-
if (!total.length) return { text: '', labels, reasons, dropped }
|
|
332
|
+
if (!total.length) return { text: '', entry, labels, reasons, dropped }
|
|
268
333
|
const clamp = (t) => (t.length > budgetChars ? `${t.slice(0, budgetChars)}\n…(截断)` : t)
|
|
269
334
|
const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
|
|
270
|
-
return { text: clamp(total.join('\n')), labels, reasons, dropped, dedupeText }
|
|
335
|
+
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, dedupeText, itemChars: used - entry.length }
|
|
271
336
|
}
|
|
272
337
|
|
|
273
338
|
/** 注册 agent/pre-step 监听,向每步请求的 enter 决策追加记忆消息(默认开)。
|
|
@@ -278,6 +343,8 @@ export function installAutoInject(ctx, config) {
|
|
|
278
343
|
const auto = (config && config.autoContext) || {}
|
|
279
344
|
if (auto.enabled === false) return
|
|
280
345
|
const cfg = cfgEngine(config)
|
|
346
|
+
// 审计配置同 cfg:安装时解析一次(与 autoContext 其余开关一致)。
|
|
347
|
+
const audit = cfgAudit(config)
|
|
281
348
|
// 每个会话一份“上次注入指纹”。用单个变量会让并发会话互相抑制注入。
|
|
282
349
|
const lastFpBySession = new Map()
|
|
283
350
|
const LAST_FP_MAX = 200
|
|
@@ -291,6 +358,9 @@ export function installAutoInject(ctx, config) {
|
|
|
291
358
|
const INJECTED_ITEMS_MAX = 600
|
|
292
359
|
// 会话步数:为 reinjectItemsAfter 提供时间轴(>0 时才用得上)。
|
|
293
360
|
const stepBySession = new Map()
|
|
361
|
+
// 会话级条目额度(S3):条数 / 字符 / 上次"条目注入"的步号。常驻任务卡不占这个额度——
|
|
362
|
+
// 它是状态快照,内容变了就该更新;被限流的是记忆条目的推送。
|
|
363
|
+
const budgetBySession = new Map()
|
|
294
364
|
// 反应窗口:本会话最近观察到的 tool/call(参数里有 git commit / npm publish / 改动的路径)。
|
|
295
365
|
// 宿主没有"工具执行前拦截"钩子,所以这是 pre-step 之外唯一能拿到的动作事实。
|
|
296
366
|
const observedBySession = new Map()
|
|
@@ -318,6 +388,7 @@ export function installAutoInject(ctx, config) {
|
|
|
318
388
|
observedBySession.clear()
|
|
319
389
|
injectedBySession.clear()
|
|
320
390
|
stepBySession.clear()
|
|
391
|
+
budgetBySession.clear()
|
|
321
392
|
})
|
|
322
393
|
}
|
|
323
394
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
@@ -336,15 +407,21 @@ export function installAutoInject(ctx, config) {
|
|
|
336
407
|
const sessionId = session && session.id
|
|
337
408
|
if (!root || !sessionId) return decision
|
|
338
409
|
const query = lastUserText(decision.messages)
|
|
339
|
-
const
|
|
410
|
+
const memoryRoot = memoryRootFor(root, config.memoryDir)
|
|
411
|
+
const store = new ProjectMemoryStore(memoryRoot).load()
|
|
340
412
|
const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
341
413
|
const boundTaskId = store.getBoundTaskId(sessionId) ? store.getBoundTaskId(sessionId) : null
|
|
342
414
|
const task = boundTaskId ? store.getTask(boundTaskId) : null
|
|
343
415
|
// 两个窗口合并进同一个就绪上下文:人类消息(先发)+ 已观察动作(反应)。
|
|
416
|
+
// 动作平面(S1):把"最近做过什么"解析成 op / 写目标 / 主机——不再把原始参数当文本搜。
|
|
344
417
|
const observed = observedBySession.get(sessionId) || []
|
|
418
|
+
const activity = activityFromCalls(observed)
|
|
345
419
|
const readiness = buildReadinessContext({
|
|
346
420
|
humanText: query || '',
|
|
347
421
|
actionText: observed.map((c) => `${c.name} ${c.arguments}`).join('\n'),
|
|
422
|
+
ops: activity.ops,
|
|
423
|
+
targets: activity.targets,
|
|
424
|
+
hosts: activity.hosts,
|
|
348
425
|
})
|
|
349
426
|
// 条目级去重:本会话已注入过、且正文未变的条目不再参与排程(cfg.reinjectItemsAfter=0 时永久,
|
|
350
427
|
// >0 时走冷却步数,用于历史可能被外部裁剪的场景)。正文变了(编辑过 insight)立刻允许重发。
|
|
@@ -364,7 +441,31 @@ export function installAutoInject(ctx, config) {
|
|
|
364
441
|
const rec = seen.get(it.id)
|
|
365
442
|
return Boolean(rec) && rec.hash === insightItemHash(it) && stepNo - rec.step < cooldown
|
|
366
443
|
}
|
|
367
|
-
|
|
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
|
+
})
|
|
368
469
|
const fp = fingerprint(built.dedupeText ?? built.text)
|
|
369
470
|
const shouldInject = Boolean(built.text) && fp !== lastFpBySession.get(sessionId)
|
|
370
471
|
let injectMessage = null
|
|
@@ -378,6 +479,12 @@ export function installAutoInject(ctx, config) {
|
|
|
378
479
|
if (r && r.id && r.hash) seen.set(r.id, { hash: r.hash, step: stepNo })
|
|
379
480
|
}
|
|
380
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
|
+
}
|
|
381
488
|
injectMessage = createUserMessage({
|
|
382
489
|
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${built.text}` }],
|
|
383
490
|
// 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
|
|
@@ -410,7 +517,21 @@ export function installAutoInject(ctx, config) {
|
|
|
410
517
|
}
|
|
411
518
|
}
|
|
412
519
|
}
|
|
413
|
-
if (injectMessage)
|
|
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
|
+
}
|
|
414
535
|
} catch (err) {
|
|
415
536
|
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
416
537
|
}
|
package/src/insight-store.js
CHANGED
|
@@ -98,11 +98,31 @@ export function normalizeInsight(raw, extra = {}) {
|
|
|
98
98
|
if (Array.isArray(raw[f]) && raw[f].length) ins[f] = [...new Set(raw[f].map((x) => String(x)))]
|
|
99
99
|
}
|
|
100
100
|
if (raw.trigger && typeof raw.trigger === 'object') {
|
|
101
|
-
// 所有 kind
|
|
101
|
+
// 所有 kind 通用。准入化 schema:when(唯一触发面)/ guard(只收窄)/ prevents(准入条件);
|
|
102
|
+
// 旧字段 keywords / symbols / actions / paths / scope 继续接受(readiness 会内存内迁移)。
|
|
102
103
|
const tr = {}
|
|
103
104
|
for (const f of ['keywords', 'symbols', 'actions', 'paths', 'scope']) {
|
|
104
105
|
if (Array.isArray(raw.trigger[f]) && raw.trigger[f].length) tr[f] = raw.trigger[f].map(String)
|
|
105
106
|
}
|
|
107
|
+
const when = raw.trigger.when
|
|
108
|
+
if (when && typeof when === 'object') {
|
|
109
|
+
const w = {}
|
|
110
|
+
for (const f of ['ops', 'writes', 'intents']) {
|
|
111
|
+
if (Array.isArray(when[f]) && when[f].length) w[f] = when[f].map(String)
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(w).length) tr.when = w
|
|
114
|
+
}
|
|
115
|
+
const guard = raw.trigger.guard
|
|
116
|
+
if (guard && typeof guard === 'object') {
|
|
117
|
+
const g = {}
|
|
118
|
+
for (const f of ['paths', 'not_paths', 'hosts', 'tags']) {
|
|
119
|
+
if (Array.isArray(guard[f]) && guard[f].length) g[f] = guard[f].map(String)
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(g).length) tr.guard = g
|
|
122
|
+
}
|
|
123
|
+
if (typeof raw.trigger.prevents === 'string' && raw.trigger.prevents.trim()) {
|
|
124
|
+
tr.prevents = raw.trigger.prevents.trim()
|
|
125
|
+
}
|
|
106
126
|
if (Object.keys(tr).length) ins.trigger = tr
|
|
107
127
|
}
|
|
108
128
|
return ins
|
package/src/ops.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// 动作平面(PLAN S1):把"这一步在做什么"从工具调用解析成有限的 op + 写目标 + 主机环境。
|
|
2
|
+
//
|
|
3
|
+
// 为什么不能继续用文本匹配:语料(路径、命令、被读文件正文)里的字符串可以冒充动作。
|
|
4
|
+
// 实测病征是 `keyword:rm` 命中 `dcterms`、`keyword:pptx` 命中一个文件扩展名。
|
|
5
|
+
// op 与 target 是低维、可枚举、可审计的:不随文本长度变化,也不会被正文污染。
|
|
6
|
+
//
|
|
7
|
+
// 分工:ops.js 只负责"把调用读成什么动作",不负责决定注入什么(那是 readiness/auto-inject)。
|
|
8
|
+
// 刻意不 import readiness:readiness 要 import 本模块的 op 闭集,反向依赖会造成循环。
|
|
9
|
+
|
|
10
|
+
/** 带扩展名的路径 token(与 readiness.extractPaths 同规则;这里就地实现以免循环依赖)。 */
|
|
11
|
+
const PATH_TOKEN = /(?:[A-Za-z0-9_.@-]+\/)*[A-Za-z0-9_.@-]+\.[A-Za-z][A-Za-z0-9]{0,7}\b/g
|
|
12
|
+
|
|
13
|
+
function extractPaths(text) {
|
|
14
|
+
const s = String(text || '')
|
|
15
|
+
if (!s) return []
|
|
16
|
+
return [...new Set([...s.matchAll(PATH_TOKEN)].map((m) => m[0]))]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** op 闭集。新增一个 op 必须同时想清楚:它是动作,不是话题。 */
|
|
20
|
+
export const OP_IDS = [
|
|
21
|
+
'file-write',
|
|
22
|
+
'file-delete',
|
|
23
|
+
'shell-run',
|
|
24
|
+
'git-commit',
|
|
25
|
+
'git-push',
|
|
26
|
+
'release',
|
|
27
|
+
'npm-publish',
|
|
28
|
+
'deploy',
|
|
29
|
+
'migrate',
|
|
30
|
+
'go-public',
|
|
31
|
+
'render-doc',
|
|
32
|
+
'index-doc',
|
|
33
|
+
'read-image',
|
|
34
|
+
'fetch-web',
|
|
35
|
+
'run-bench',
|
|
36
|
+
'query-memory',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 兜底 op:它们描述的是"发生了某类操作",信息量低、假阳性高。
|
|
41
|
+
* 允许写进 `when.ops`,但自检会 warn——兜底 op 只该命中兜底级记忆。
|
|
42
|
+
*/
|
|
43
|
+
export const FALLBACK_OPS = new Set(['shell-run', 'file-write', 'query-memory'])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 弱 op:日常动作。它本身不构成边界(每天都在提交),所以当条目**已经有更精确的 writes**
|
|
47
|
+
* 时,迁移会把弱 op 丢掉——留着它只会把"改这个文件时"扩大成"任何一次提交时"。
|
|
48
|
+
* 实测:`git-commit` 让一条"别删内部文档"的教训在发版场景里被注入。
|
|
49
|
+
*/
|
|
50
|
+
export const WEAK_OPS = new Set([...FALLBACK_OPS, 'git-commit', 'git-push'])
|
|
51
|
+
|
|
52
|
+
/** 旧 action id → op。旧值里有一批从来没进过 ACTION_LEXICON 的死值,这里给它们一个归宿。 */
|
|
53
|
+
export const LEGACY_ACTION_ALIAS = {
|
|
54
|
+
'git-add': 'git-commit',
|
|
55
|
+
'npm-pack': 'npm-publish',
|
|
56
|
+
'release': 'release',
|
|
57
|
+
'generate-pptx': 'render-doc',
|
|
58
|
+
'render': 'render-doc',
|
|
59
|
+
'read-image': 'read-image',
|
|
60
|
+
'web-fetch': 'fetch-web',
|
|
61
|
+
'benchmark': 'run-bench',
|
|
62
|
+
'cleanup': 'file-delete',
|
|
63
|
+
'delete': 'file-delete',
|
|
64
|
+
'recover': null, // 没有对应动作:恢复是意图,不是工具动作
|
|
65
|
+
'research': null,
|
|
66
|
+
'survey': null,
|
|
67
|
+
'interview-prep': null,
|
|
68
|
+
'write-resume': null,
|
|
69
|
+
'report-metrics': null,
|
|
70
|
+
'write-docs': null,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const WRITE_TOOLS = new Set(['write', 'edit', 'multi_edit', 'apply_patch', 'str_replace', 'create_file', 'notebook_edit'])
|
|
74
|
+
const READ_IMAGE_TOOLS = new Set(['read_image'])
|
|
75
|
+
const INDEX_TOOLS = new Set(['index_doc', 'index_repo', 'watch_repo'])
|
|
76
|
+
const FETCH_TOOLS = new Set(['web_fetch', 'web_search'])
|
|
77
|
+
const SHELL_TOOLS = new Set(['bash', 'shell', 'pwsh', 'powershell', 'run', 'exec', 'job_run'])
|
|
78
|
+
const QUERY_TOOLS = new Set(['query_memory', 'recall'])
|
|
79
|
+
|
|
80
|
+
/** bash 命令 → op。顺序有意义:先具体后兜底。 */
|
|
81
|
+
const SHELL_OP_RULES = [
|
|
82
|
+
[/\bnpm\s+(publish|pack)\b/, 'npm-publish'],
|
|
83
|
+
[/\bnpm\s+version\b/, 'release'],
|
|
84
|
+
[/\bgit\s+tag\b/, 'release'],
|
|
85
|
+
[/\bgit\s+push\b/, 'git-push'],
|
|
86
|
+
[/\bgit\s+(commit|add)\b/, 'git-commit'],
|
|
87
|
+
[/\bgit\s+(checkout|restore|reset)\b/, 'file-write'],
|
|
88
|
+
// `\brm\b` 而不是 `rm\s+-[rf]`:后者被 `\b` 卡在 'rm -rf' 的 r 后面(f 还是词字符),永远匹配不上。
|
|
89
|
+
[/\b(rm|git\s+rm|unlink|rmdir|shred)\b/, 'file-delete'],
|
|
90
|
+
[/\b(kubectl|docker\s+push|systemctl\s+restart|pm2\s+(deploy|restart)|vercel|netlify\s+deploy)\b/, 'deploy'],
|
|
91
|
+
[/\b(migrate|alembic|prisma\s+migrate|db:migrate)\b/, 'migrate'],
|
|
92
|
+
// 注意:只认"渲染器",不认 .pptx 扩展名——否则给 pptx 改个时间戳也会命中"做 PPT"
|
|
93
|
+
[/(pptxgenjs|soffice|libreoffice|render\.sh|marp|pandoc|slidev)/, 'render-doc'],
|
|
94
|
+
[/(bench\.mjs|\bbench(mark)?\b)/, 'run-bench'],
|
|
95
|
+
[/\b(curl|wget|web_fetch)\b/, 'fetch-web'],
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
/** 会改动文件系统的 shell 动作前缀:只有这些命令的路径才算"写目标"。 */
|
|
99
|
+
const SHELL_WRITE_RE = /\b(rm|rmdir|unlink|shred|mv|cp|touch|tee|truncate|mkdir)\b|>>?\s*\S|\bsed\s+-i\b/
|
|
100
|
+
|
|
101
|
+
const WSL_HINT_RE = /\/mnt\/[a-z]\//i
|
|
102
|
+
|
|
103
|
+
function safeJson(text) {
|
|
104
|
+
if (typeof text !== 'string' || !text.trim()) return null
|
|
105
|
+
try {
|
|
106
|
+
const v = JSON.parse(text)
|
|
107
|
+
return v && typeof v === 'object' ? v : null
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function argsFields(argsText) {
|
|
114
|
+
const obj = safeJson(argsText)
|
|
115
|
+
if (!obj) return {}
|
|
116
|
+
const pick = (...keys) => keys
|
|
117
|
+
.map((k) => obj[k])
|
|
118
|
+
.filter((v) => typeof v === 'string' && v)
|
|
119
|
+
.join(' ')
|
|
120
|
+
return {
|
|
121
|
+
command: pick('command', 'cmd', 'script', 'code'),
|
|
122
|
+
path: pick('file_path', 'path', 'file', 'target', 'notebook_path'),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** bash 命令 → { ops, targets, hosts }。 */
|
|
127
|
+
export function classifyShellCommand(command) {
|
|
128
|
+
const cmd = String(command || '')
|
|
129
|
+
const ops = []
|
|
130
|
+
if (cmd) {
|
|
131
|
+
for (const [re, op] of SHELL_OP_RULES) {
|
|
132
|
+
if (re.test(cmd) && !ops.includes(op)) ops.push(op)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!ops.length) ops.push('shell-run')
|
|
136
|
+
const targets = SHELL_WRITE_RE.test(cmd) ? extractPaths(cmd) : []
|
|
137
|
+
const hosts = WSL_HINT_RE.test(cmd) || /\b(powershell|pwsh|cmd)\.exe\b/i.test(cmd) ? ['wsl'] : []
|
|
138
|
+
return { ops, targets, hosts }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 一次工具调用 → { ops, targets, hosts }。
|
|
143
|
+
* @param {string} name 工具名
|
|
144
|
+
* @param {string} argsText 工具参数(JSON 字符串或裸文本)
|
|
145
|
+
*/
|
|
146
|
+
export function classifyToolCall(name, argsText) {
|
|
147
|
+
const tool = String(name || '').toLowerCase()
|
|
148
|
+
const { command, path } = argsFields(argsText)
|
|
149
|
+
if (SHELL_TOOLS.has(tool)) return classifyShellCommand(command || String(argsText || ''))
|
|
150
|
+
if (READ_IMAGE_TOOLS.has(tool)) return { ops: ['read-image'], targets: [], hosts: [] }
|
|
151
|
+
if (INDEX_TOOLS.has(tool)) return { ops: ['index-doc'], targets: [], hosts: [] }
|
|
152
|
+
if (FETCH_TOOLS.has(tool)) return { ops: ['fetch-web'], targets: [], hosts: [] }
|
|
153
|
+
if (QUERY_TOOLS.has(tool)) return { ops: ['query-memory'], targets: [], hosts: [] }
|
|
154
|
+
if (WRITE_TOOLS.has(tool)) {
|
|
155
|
+
const targets = path ? [path, ...extractPaths(path)] : []
|
|
156
|
+
const hosts = targets.some((t) => WSL_HINT_RE.test(t)) ? ['wsl'] : []
|
|
157
|
+
return { ops: ['file-write'], targets: [...new Set(targets)], hosts }
|
|
158
|
+
}
|
|
159
|
+
return { ops: [], targets: [], hosts: [] }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 把一组已观察到的工具调用合成这一个线程的动态面。
|
|
164
|
+
* @param {{name?: string, arguments?: string}[]} calls
|
|
165
|
+
*/
|
|
166
|
+
export function activityFromCalls(calls) {
|
|
167
|
+
const ops = new Set()
|
|
168
|
+
const targets = new Set()
|
|
169
|
+
const hosts = new Set()
|
|
170
|
+
for (const call of calls || []) {
|
|
171
|
+
const r = classifyToolCall(call?.name, call?.arguments)
|
|
172
|
+
for (const op of r.ops) ops.add(op)
|
|
173
|
+
for (const t of r.targets) targets.add(t)
|
|
174
|
+
for (const h of r.hosts) hosts.add(h)
|
|
175
|
+
}
|
|
176
|
+
return { ops: [...ops], targets: [...targets], hosts: [...hosts] }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 没拿到结构化调用时的兜底:直接对一段文本跑 shell 规则。
|
|
181
|
+
* 用于 `actionText` 形式的就绪上下文(测试与老宿主),以及人类消息里的动作词。
|
|
182
|
+
*/
|
|
183
|
+
export function activityFromText(text) {
|
|
184
|
+
const s = String(text || '')
|
|
185
|
+
if (!s) return { ops: [], targets: [], hosts: [] }
|
|
186
|
+
const lines = s.split('\n').filter(Boolean)
|
|
187
|
+
const ops = new Set()
|
|
188
|
+
const targets = new Set()
|
|
189
|
+
const hosts = new Set()
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
// 先按"工具调用"解析(`edit {"file_path":"..."}`):结构化参数比正则猜命令可靠得多。
|
|
192
|
+
const call = /^\s*([A-Za-z_][\w-]*)\s+(\{[\s\S]*\})\s*$/.exec(line)
|
|
193
|
+
const r = call ? classifyToolCall(call[1], call[2]) : classifyShellCommand(line)
|
|
194
|
+
// 纯文本行里没有 shell 动作时不要退化成 shell-run(那是噪音源)
|
|
195
|
+
if (r.ops.length === 1 && r.ops[0] === 'shell-run' && !/\b\w+\s+-/.test(line)) continue
|
|
196
|
+
for (const op of r.ops) ops.add(op)
|
|
197
|
+
for (const t of r.targets) targets.add(t)
|
|
198
|
+
for (const h of r.hosts) hosts.add(h)
|
|
199
|
+
}
|
|
200
|
+
return { ops: [...ops], targets: [...targets], hosts: [...hosts] }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 旧 action id(含死值)→ op;无法映射返回 null。 */
|
|
204
|
+
export function opForLegacyAction(action) {
|
|
205
|
+
const a = String(action || '')
|
|
206
|
+
if (!a) return null
|
|
207
|
+
if (OP_IDS.includes(a)) return a
|
|
208
|
+
if (a in LEGACY_ACTION_ALIAS) return LEGACY_ACTION_ALIAS[a]
|
|
209
|
+
return null
|
|
210
|
+
}
|