@yolk_vat-y/dsh-project-memory 0.5.7 → 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 +107 -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 +114 -18
- 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 +13 -2
- package/src/commands/task-actions.js +13 -2
- package/src/commands/workflow.js +54 -0
- package/src/index.js +24 -8
- package/src/insight-store.js +39 -0
- package/src/tools/query-memory.js +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolk_vat-y/dsh-project-memory",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"description": "Persistent project memory for dsh agents: index docs (PDF/Markdown/text) and code symbols into a searchable per-workspace store, recall them with cited sources, and keep experience entries (problems -> solutions) searchable on demand.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"url": "https://github.com/00080000/dsh-project-memory.git"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs",
|
|
21
|
+
"test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs && node test/client-icons.test.mjs && node test/client-slash.test.mjs && node test/workflow-command.test.mjs && node test/client-session-id.test.mjs",
|
|
22
22
|
"eval:injection": "node test/injection-scenarios.test.mjs",
|
|
23
23
|
"selfcheck:triggers": "node test/injection-scenarios.test.mjs --selfcheck",
|
|
24
24
|
"bench": "node scripts/bench.mjs",
|
|
@@ -92,10 +92,7 @@
|
|
|
92
92
|
},
|
|
93
93
|
"client": {
|
|
94
94
|
"inject": [
|
|
95
|
-
"@deepseek-ai/dsh-client-locale"
|
|
96
|
-
"@deepseek-ai/dsh-client-ui-primitives",
|
|
97
|
-
"@deepseek-ai/dsh-client-ui-slots",
|
|
98
|
-
"@deepseek-ai/dsh-client-store"
|
|
95
|
+
"@deepseek-ai/dsh-client-locale"
|
|
99
96
|
],
|
|
100
97
|
"platform": "web"
|
|
101
98
|
}
|
package/src/audit.js
CHANGED
|
@@ -14,6 +14,12 @@ import path from 'node:path'
|
|
|
14
14
|
export const AUDIT_FILE = 'injection-audit.jsonl'
|
|
15
15
|
const DEFAULT_MAX_BYTES = 256 * 1024
|
|
16
16
|
|
|
17
|
+
// 影子记录:与主审计**分开一个文件**。主审计只记"真的注入了"的步,静默步(包括对照组那种
|
|
18
|
+
// "本该零注入"的步)完全不落痕,于是日志回答不了"换个阈值/换个判据会怎样"。影子记录每步都写,
|
|
19
|
+
// 带全部候选的特征与判据结果,用来 (a) 离线重放任一阈值、(b) 攒"特征 → 该不该注入"的训练样本。
|
|
20
|
+
export const SHADOW_FILE = 'admission-shadow.jsonl'
|
|
21
|
+
const DEFAULT_SHADOW_MAX_BYTES = 2 * 1024 * 1024
|
|
22
|
+
|
|
17
23
|
/** 审计配置:默认开(观测是这个插件唯一的仪表盘),显式 `auditLog: false` 才关。 */
|
|
18
24
|
export function cfgAudit(config) {
|
|
19
25
|
const c = (config && config.autoContext) || {}
|
|
@@ -27,6 +33,63 @@ export function auditFileFor(memoryDir) {
|
|
|
27
33
|
return path.join(memoryDir, AUDIT_FILE)
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/** 影子记录配置:默认开(它只写盘、不进 prompt、不花 token),显式 `shadowLog: false` 才关。 */
|
|
37
|
+
export function cfgShadow(config) {
|
|
38
|
+
const c = (config && config.autoContext) || {}
|
|
39
|
+
return {
|
|
40
|
+
enabled: c.shadowLog !== false,
|
|
41
|
+
maxBytes: typeof c.shadowMaxBytes === 'number' && c.shadowMaxBytes > 0 ? c.shadowMaxBytes : DEFAULT_SHADOW_MAX_BYTES,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function shadowFileFor(memoryDir) {
|
|
46
|
+
return path.join(memoryDir, SHADOW_FILE)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 一步的影子记录(纯函数,可单测)。与主审计的差别只有两点,但都是关键:
|
|
51
|
+
* - **每步都写**(含零注入的静默步与对照组),所以"没注入"也留下了可复盘的事实;
|
|
52
|
+
* - 带**全部候选**的判据特征(cov/matched/support/relative)与场景(query/ops/writes),
|
|
53
|
+
* 所以能离线回答"阈值换成 0.40 会注入什么"、并攒出"特征 → 该不该注入"的样本。
|
|
54
|
+
* @param {object} input { sessionId, root, step, query, ops, writes, reasons, candidates, silence }
|
|
55
|
+
*/
|
|
56
|
+
export function shadowRecordFrom(input) {
|
|
57
|
+
const reasons = Array.isArray(input.reasons) ? input.reasons : []
|
|
58
|
+
const candidates = Array.isArray(input.candidates) ? input.candidates : []
|
|
59
|
+
return {
|
|
60
|
+
at: new Date().toISOString(),
|
|
61
|
+
session: input.sessionId || null,
|
|
62
|
+
root: input.root || null,
|
|
63
|
+
step: typeof input.step === 'number' ? input.step : null,
|
|
64
|
+
// 人类消息截断保存:没有它就判不了"这次注入该不该"——标签要能回放到当时的场景。
|
|
65
|
+
query: typeof input.query === 'string' ? input.query.slice(0, 300) : '',
|
|
66
|
+
ops: (Array.isArray(input.ops) ? input.ops : []).slice(0, 12),
|
|
67
|
+
writes: (Array.isArray(input.writes) ? input.writes : []).slice(0, 12),
|
|
68
|
+
injected: reasons.map((r) => ({ id: r.id, channel: r.channel, why: r.why })),
|
|
69
|
+
candidates,
|
|
70
|
+
silence: input.silence || null,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 追加一行影子记录。返回是否写入成功(仅供测试断言)。与主审计同约定:任何异常一律吞掉,
|
|
76
|
+
* 绝不影响宿主请求。
|
|
77
|
+
*/
|
|
78
|
+
export function appendShadowAudit(memoryDir, record, cfg) {
|
|
79
|
+
const c = cfg || {}
|
|
80
|
+
if (c.enabled === false) return false
|
|
81
|
+
if (!memoryDir || !record) return false
|
|
82
|
+
try {
|
|
83
|
+
mkdirSync(memoryDir, { recursive: true })
|
|
84
|
+
const file = shadowFileFor(memoryDir)
|
|
85
|
+
rotateIfOversized(file, c.maxBytes || DEFAULT_SHADOW_MAX_BYTES)
|
|
86
|
+
appendFileSync(file, `${JSON.stringify(record)}\n`)
|
|
87
|
+
return true
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
30
93
|
/**
|
|
31
94
|
* 一次注入的审计记录(纯函数,可单测)。字段刻意保持"能直接回答两个问题":
|
|
32
95
|
* 注入了什么(injected)、为什么没注入别的(dropped)。
|
package/src/auto-inject.js
CHANGED
|
@@ -11,16 +11,47 @@ import { ProjectMemoryStore } from './store.js'
|
|
|
11
11
|
import { memoryRootFor } from './util/fs.js'
|
|
12
12
|
import { insightMatchText, normalizedTokenOverlap } from './similarity.js'
|
|
13
13
|
import { BoundedMap, SessionCache } from './util/session-cache.js'
|
|
14
|
-
import { cfgInsight, GlobalStore, defaultGlobalFile } from './insight-store.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
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 不传递信息。
|
|
@@ -67,9 +98,13 @@ export function cfgEngine(config) {
|
|
|
67
98
|
// 每会话条目注入的上限(条数 / 字符):预算只能是上限,不是目标。
|
|
68
99
|
maxItemsPerSession: typeof c.maxItemsPerSession === 'number' && c.maxItemsPerSession >= 0 ? c.maxItemsPerSession : 12,
|
|
69
100
|
maxItemCharsPerSession: typeof c.maxItemCharsPerSession === 'number' && c.maxItemCharsPerSession >= 0 ? c.maxItemCharsPerSession : 4000,
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
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,
|
|
73
108
|
// 提示通道还要求至少这么多个共同词:单个通用词("插件")也能拿到 coverage=1.00。
|
|
74
109
|
hintMinMatched: typeof c.hintMinMatched === 'number' && c.hintMinMatched >= 0 ? c.hintMinMatched : 2,
|
|
75
110
|
// 通道级沉默:查询里能在语料中找到对应的词占比低于这个值时,整条提示通道本轮不出声。
|
|
@@ -98,8 +133,8 @@ export function lastUserText(messages) {
|
|
|
98
133
|
// 上一步的注入块会变成这一步的就绪查询(自激:拿自己注入的内容再检索一遍)。
|
|
99
134
|
if (m.source && m.source.kind === 'user') return t.trim()
|
|
100
135
|
// 本插件注入的块同样是 role=user。无 source 的老宿主下若把它当兜底查询,
|
|
101
|
-
// 就成了"拿自己上一步注入的内容再检索一遍"的自激——正是
|
|
102
|
-
if (m.source
|
|
136
|
+
// 就成了"拿自己上一步注入的内容再检索一遍"的自激——正是 isOwnInjection 这道闸要防的。
|
|
137
|
+
if (isOwnInjection(m.source)) continue
|
|
103
138
|
if (!fallback) fallback = t.trim() // 无 source 的消息(老宿主 / 测试)兜底
|
|
104
139
|
}
|
|
105
140
|
return fallback
|
|
@@ -226,7 +261,11 @@ function collectTriggered(cands, ctx) {
|
|
|
226
261
|
*/
|
|
227
262
|
function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
228
263
|
const hints = []
|
|
229
|
-
|
|
264
|
+
// 影子记录用:本步**全部**被评过分的候选 + 特征 + 判据结果。与 hints/dropped 分开算——
|
|
265
|
+
// 现有两类记录都只覆盖"过得了相对阈值的那部分",恰恰缺了"因为没到阈值而从未被考虑"的候选,
|
|
266
|
+
// 而那正是离线重放阈值时需要的那批。这里只做加法,不动任何现有判据。
|
|
267
|
+
const candidates = []
|
|
268
|
+
if (!cands.length) return { hints, candidates }
|
|
230
269
|
if (typeof cfg.relevanceMin === 'number') {
|
|
231
270
|
// 兼容:显式 relevanceMin → 旧的绝对 overlap 判据(老 profile 行为不变)
|
|
232
271
|
for (const it of cands) {
|
|
@@ -234,10 +273,10 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
234
273
|
if (score >= cfg.relevanceMin) hints.push({ it, score, why: `overlap:${score.toFixed(3)}` })
|
|
235
274
|
}
|
|
236
275
|
hints.sort((a, b) => b.score - a.score)
|
|
237
|
-
return hints
|
|
276
|
+
return { hints, candidates } // 旧判据路径不产出影子候选(已废弃,无重放价值)
|
|
238
277
|
}
|
|
239
278
|
const q = hintQueryText(hintQuery)
|
|
240
|
-
if (!q) return hints
|
|
279
|
+
if (!q) return { hints, candidates }
|
|
241
280
|
const byId = new Map(cands.map((it) => [it.id, it]))
|
|
242
281
|
// 覆盖率语料必须与 BM25 排序同源(insightScoringText):否则"只在 fix/solution 里有匹配"
|
|
243
282
|
// 的查询词 df=0 → supportRatio=0 → 整条提示通道沉默,而排序明明给了它高分。
|
|
@@ -272,7 +311,36 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
272
311
|
}
|
|
273
312
|
hints.push({ it, score: r.score, why: `relative:${(r.score / (scored[0].score || 1)).toFixed(2)} cov:${ev.coverage.toFixed(2)}/${ev.matched}` })
|
|
274
313
|
}
|
|
275
|
-
|
|
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 }
|
|
276
344
|
}
|
|
277
345
|
|
|
278
346
|
/**
|
|
@@ -317,7 +385,7 @@ export function buildInjection(opts) {
|
|
|
317
385
|
// procedure 不进提示通道:它要过 trigger.scope(见 scoreHints 注释)。
|
|
318
386
|
const consumed = new Set(triggered.map((t) => t.it.id))
|
|
319
387
|
const hintCands = skipItems ? [] : cands.filter((it) => !consumed.has(it.id) && it.kind !== 'procedure')
|
|
320
|
-
const hints = scoreHints({
|
|
388
|
+
const { hints, candidates } = scoreHints({
|
|
321
389
|
cands: hintCands,
|
|
322
390
|
query,
|
|
323
391
|
humanText: ctx.humanText,
|
|
@@ -368,7 +436,7 @@ export function buildInjection(opts) {
|
|
|
368
436
|
// 整块只受单轮预算(maxTokens)约束;会话条目额度已在 place() 里单独扣过。
|
|
369
437
|
const clamp = (t) => (t.length > stepBudget ? `${t.slice(0, stepBudget)}\n…(截断)` : t)
|
|
370
438
|
const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
|
|
371
|
-
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, dedupeText, itemChars: itemUsed }
|
|
439
|
+
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, candidates, dedupeText, itemChars: itemUsed }
|
|
372
440
|
}
|
|
373
441
|
|
|
374
442
|
// 每会话状态的会话数上限:六张表共用(旧实现把同一个 200 在五处各写一遍)。
|
|
@@ -439,6 +507,7 @@ export function installAutoInject(ctx, config) {
|
|
|
439
507
|
const cfg = cfgEngine(config)
|
|
440
508
|
// 审计配置同 cfg:安装时解析一次(与 autoContext 其余开关一致)。
|
|
441
509
|
const audit = cfgAudit(config)
|
|
510
|
+
const shadow = cfgShadow(config)
|
|
442
511
|
const sessions = new InjectionSessions()
|
|
443
512
|
|
|
444
513
|
installCallObserver(ctx, sessions)
|
|
@@ -455,7 +524,7 @@ export function installAutoInject(ctx, config) {
|
|
|
455
524
|
if (!decision) return fallback()
|
|
456
525
|
if (decision.kind !== 'enter') return decision
|
|
457
526
|
try {
|
|
458
|
-
return (await injectForStep({ payload, decision, config, cfg, audit, sessions })) || decision
|
|
527
|
+
return (await injectForStep({ payload, decision, config, cfg, audit, shadow, sessions })) || decision
|
|
459
528
|
} catch (err) {
|
|
460
529
|
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
461
530
|
return decision
|
|
@@ -467,7 +536,7 @@ export function installAutoInject(ctx, config) {
|
|
|
467
536
|
* 一个 pre-step 的注入决策:解析会话 → 排程 → 必要时构造消息。
|
|
468
537
|
* 返回 null 表示本步无事可做(交回原决策)。
|
|
469
538
|
*/
|
|
470
|
-
async function injectForStep({ payload, decision, config, cfg, audit, sessions }) {
|
|
539
|
+
async function injectForStep({ payload, decision, config, cfg, audit, shadow, sessions }) {
|
|
471
540
|
const session = payload && payload.agent && payload.agent.session
|
|
472
541
|
const root = session && session.header && session.header.cwd
|
|
473
542
|
const sessionId = session && session.id
|
|
@@ -518,6 +587,21 @@ async function injectForStep({ payload, decision, config, cfg, audit, sessions }
|
|
|
518
587
|
// 留痕仍然记账(去重 + 上限),只是默认不外泄到用户的终端。
|
|
519
588
|
recordDropped({ sessions, sessionId, dropped: built.dropped, budgetLog: cfg.budgetLog })
|
|
520
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
|
+
|
|
521
605
|
const fp = fingerprint(built.dedupeText ?? built.text)
|
|
522
606
|
if (!built.text || fp === sessions.lastText.peek(sessionId)) return null
|
|
523
607
|
sessions.lastText.set(sessionId, fp)
|
|
@@ -534,6 +618,19 @@ async function injectForStep({ payload, decision, config, cfg, audit, sessions }
|
|
|
534
618
|
quota.lastStep = stepNo
|
|
535
619
|
}
|
|
536
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:记账是旁路,不能拖累注入 */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
537
634
|
// S0 观测:只记真的进了上下文的那一次(dropped 单独出现不写,否则每步刷屏)。
|
|
538
635
|
appendInjectionAudit(memoryRoot, auditRecordFrom({
|
|
539
636
|
sessionId,
|
|
@@ -594,10 +691,9 @@ function injectionMessage(text) {
|
|
|
594
691
|
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${text}` }],
|
|
595
692
|
// 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
|
|
596
693
|
// 宿主 ContextFormed 是判别联合:snapshot 必须带 sections(notice 才需要 summary)。
|
|
597
|
-
//
|
|
694
|
+
// kind 必须是生产者自有的(v4 没有 'plugin' 兜底 kind),见 SOURCE_KIND 的注释。
|
|
598
695
|
source: {
|
|
599
|
-
kind:
|
|
600
|
-
plugin: 'dsh-project-memory',
|
|
696
|
+
kind: SOURCE_KIND,
|
|
601
697
|
form: 'snapshot',
|
|
602
698
|
sections: [{ name: 'project-memory', text }],
|
|
603
699
|
},
|
|
@@ -120,6 +120,14 @@ export function MemoryView({
|
|
|
120
120
|
const lastRunRef = useRef(0)
|
|
121
121
|
|
|
122
122
|
const refresh = useCallback(async (force = false): Promise<void> => {
|
|
123
|
+
// 没有活跃会话时不发命令、也不报错:面板顶部已经显示「还没有会话」,
|
|
124
|
+
// 再报一次"同步失败"会让人以为记忆库坏了(0.1.7 上会话快照结构变化时就是这个表象)。
|
|
125
|
+
if (!sessionId) {
|
|
126
|
+
setPayload(null)
|
|
127
|
+
setError(null)
|
|
128
|
+
setLoading(false)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
123
131
|
if (inflightRef.current) return
|
|
124
132
|
const now = Date.now()
|
|
125
133
|
if (!force && now - lastRunRef.current < 800) return
|
|
@@ -128,7 +136,7 @@ export function MemoryView({
|
|
|
128
136
|
setLoading(true)
|
|
129
137
|
setError(null)
|
|
130
138
|
try {
|
|
131
|
-
const res = await runLine(`/insight list ${scope}`)
|
|
139
|
+
const res = await runLine(`/tasks insight list ${scope}`)
|
|
132
140
|
if (!res.ok) {
|
|
133
141
|
setError(res.text)
|
|
134
142
|
setPayload(null)
|
|
@@ -152,7 +160,7 @@ export function MemoryView({
|
|
|
152
160
|
setBusy(true)
|
|
153
161
|
setError(null)
|
|
154
162
|
try {
|
|
155
|
-
const line = `/insight ${action} ${scope} ${id}${action === 'demote' && scope === 'project' && boundTaskId ? ` ${boundTaskId}` : ''}`
|
|
163
|
+
const line = `/tasks insight ${action} ${scope} ${id}${action === 'demote' && scope === 'project' && boundTaskId ? ` ${boundTaskId}` : ''}`
|
|
156
164
|
const res = await runLine(line)
|
|
157
165
|
if (!res.ok) setError(res.text)
|
|
158
166
|
else void refresh(true)
|
|
@@ -230,8 +238,8 @@ export function MemoryView({
|
|
|
230
238
|
fields.trigger = { keywords: form.trigger.split(/[,,]/).map((s) => s.trim()).filter(Boolean) }
|
|
231
239
|
}
|
|
232
240
|
const line = editingId
|
|
233
|
-
? `/insight edit ${scope} ${editingId} ${JSON.stringify(fields)}`
|
|
234
|
-
: `/insight save ${scope} ${JSON.stringify(fields)}`
|
|
241
|
+
? `/tasks insight edit ${scope} ${editingId} ${JSON.stringify(fields)}`
|
|
242
|
+
: `/tasks insight save ${scope} ${JSON.stringify(fields)}`
|
|
235
243
|
const res = await runLine(line)
|
|
236
244
|
if (!res.ok) {
|
|
237
245
|
setError(res.text)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 目的:大 JSON 载荷绝不整段渲染进对话(否则切视图会刷屏)。
|
|
5
5
|
*/
|
|
6
6
|
import { useEffect, useRef } from 'react'
|
|
7
|
-
import { IconFolderOpenOutline16 } from '
|
|
7
|
+
import { IconFolderOpenOutline16 } from './icons.ts'
|
|
8
8
|
import { parseTaskPayloadText, taskDataStore } from './task-data-store.ts'
|
|
9
9
|
import { parseInsightPayloadText } from './MemoryView.tsx'
|
|
10
10
|
import { taskUIStore } from './task-ui-store.ts'
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
* 纯渲染 + 卡片局部行内编辑/拖拽状态,动作经 props 回调交给容器(TaskPanel.tsx)。
|
|
4
4
|
*/
|
|
5
5
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
6
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
7
|
+
// 图标走兼容层:0.1.5 用 IconXxx14/16,0.1.7+ 改成 IconXxxRegular + size prop
|
|
6
8
|
import {
|
|
7
|
-
Button,
|
|
8
9
|
IconChevronDownOutline14,
|
|
9
10
|
IconChevronUpOutline14,
|
|
10
11
|
IconCheckOutline16,
|
|
11
12
|
IconPlayOutline16,
|
|
12
13
|
IconFolderOpenOutline16,
|
|
13
|
-
} from '
|
|
14
|
+
} from './icons.ts'
|
|
14
15
|
import { createTranslate } from './locales.ts'
|
|
15
16
|
import { useTaskDrag } from './task-hooks.ts'
|
|
16
17
|
import { type Task, type TaskInsight, type TaskStep } from './task-data-store.ts'
|
package/src/client/TaskPanel.tsx
CHANGED
|
@@ -4,18 +4,20 @@
|
|
|
4
4
|
* Presentational 组件在 TaskComponents.tsx
|
|
5
5
|
*/
|
|
6
6
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
7
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
8
|
+
// 图标走兼容层:0.1.5 用 IconXxx14/16,0.1.7+ 改成 IconXxxRegular + size prop
|
|
7
9
|
import {
|
|
8
|
-
Button,
|
|
9
10
|
IconChevronDownOutline14,
|
|
10
11
|
IconCloseOutline16,
|
|
11
12
|
IconFolderOpenOutline16,
|
|
12
13
|
IconQuestionOutline14,
|
|
13
|
-
} from '
|
|
14
|
+
} from './icons.ts'
|
|
14
15
|
import { createTranslate, zh, en } from './locales.ts'
|
|
15
16
|
import { useTaskData, useTaskDataActions, taskDataStore, parseTaskPayloadText, type TaskStep } from './task-data-store.ts'
|
|
16
17
|
import { useTaskUI, useTaskUIActions } from './task-ui-store.ts'
|
|
17
18
|
import { MiniBar, TaskCard } from './TaskComponents.tsx'
|
|
18
19
|
import { MemoryView, type InsightScope } from './MemoryView.tsx'
|
|
20
|
+
import { pickSessionId } from './session-id.js'
|
|
19
21
|
import css from './TaskPanel.module.css'
|
|
20
22
|
|
|
21
23
|
const NS = 'dsh-project-memory'
|
|
@@ -36,11 +38,9 @@ function useSessionId(ctx: any): string | null {
|
|
|
36
38
|
if (!list || typeof list.subscribe !== 'function') return
|
|
37
39
|
return list.subscribe(() => force((n) => n + 1))
|
|
38
40
|
}, [ctx])
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const first = Array.isArray(snap.items) ? snap.items.find((s: any) => !s.blank) ?? snap.items[0] : undefined
|
|
43
|
-
return first?.sessionId ?? null
|
|
41
|
+
// 快照结构在 dsh 0.1.7 变过(current/items → ids/byId):解析与兼容都在 session-id.js,
|
|
42
|
+
// 那里有单测;这里只负责订阅与取值。
|
|
43
|
+
return pickSessionId(ctx?.sessions?.list?.getSnapshot?.())
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
class PanelErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
|
|
@@ -134,7 +134,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
134
134
|
await runLine('/tasks')
|
|
135
135
|
const newBoundId = taskDataStore.getSnapshot().boundTaskId
|
|
136
136
|
if (newBoundId && newBoundId !== prevBoundId) {
|
|
137
|
-
await runLine(`/
|
|
137
|
+
await runLine(`/tasks switch ${newBoundId}`)
|
|
138
138
|
}
|
|
139
139
|
} finally {
|
|
140
140
|
setSyncing(false)
|
|
@@ -145,7 +145,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
145
145
|
if (syncing) return
|
|
146
146
|
setSyncing(true)
|
|
147
147
|
try {
|
|
148
|
-
await runLine(`/
|
|
148
|
+
await runLine(`/tasks ${verb} ${taskId}`)
|
|
149
149
|
} finally {
|
|
150
150
|
setSyncing(false)
|
|
151
151
|
}
|
|
@@ -156,7 +156,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
156
156
|
if (syncing) return
|
|
157
157
|
setSyncing(true)
|
|
158
158
|
try {
|
|
159
|
-
await runLine(`/insight ${action} task ${id}`)
|
|
159
|
+
await runLine(`/tasks insight ${action} task ${id}`)
|
|
160
160
|
await refresh()
|
|
161
161
|
} finally {
|
|
162
162
|
setSyncing(false)
|
|
@@ -164,7 +164,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
const pushSteps = (taskId: string, steps: TaskStep[]): void => {
|
|
167
|
-
void runLine(`/
|
|
167
|
+
void runLine(`/tasks todos ${JSON.stringify(steps.map((s) => ({ content: s.content, status: s.status })))}`)
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
const cycleStatus = (taskId: string, index: number) => {
|
|
@@ -206,7 +206,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
206
206
|
if (!task) return
|
|
207
207
|
const same = task.title === trimmed
|
|
208
208
|
if (same || !trimmed) return
|
|
209
|
-
void runLine(`/
|
|
209
|
+
void runLine(`/tasks rename ${taskId} ${JSON.stringify(trimmed)}`)
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
const expand = () => {
|
|
@@ -421,7 +421,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
421
421
|
expanded={expanded}
|
|
422
422
|
onToggleExpand={() => uiActions.toggleTaskExpanded(task.id)}
|
|
423
423
|
onSwitch={() => handleAction('switch', task.id)}
|
|
424
|
-
onUnbind={() => runLine('/
|
|
424
|
+
onUnbind={() => runLine('/tasks unbind')}
|
|
425
425
|
onArchive={() => handleAction('archive', task.id)}
|
|
426
426
|
onRename={(value) => commitTitle(task.id, value)}
|
|
427
427
|
onEditStep={(index, value) => commitStepText(task.id, index, value)}
|
package/src/client/client.ts
CHANGED
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 只依赖宿主 seed 提供的模块(react / dsh-client-ui-primitives),
|
|
12
12
|
* 数据经 remote.commands.execute 执行 /tasks 命令获取 JSON 快照。
|
|
13
|
+
*
|
|
14
|
+
* 另有一个可选增强:自建的 `/` 菜单源(slash.ts),把三条命令收进一个带图标和
|
|
15
|
+
* 小节标题的「工作流」组。它**整体是可选的**——宿主没有 slash 触发器服务时静默跳过。
|
|
13
16
|
*/
|
|
14
17
|
import { createElement } from 'react'
|
|
15
18
|
import { TaskPanelEntry } from './TaskPanel.tsx'
|
|
16
19
|
import { TaskCommandNode } from './TaskCommandNode.tsx'
|
|
20
|
+
import { createSlashSource } from './slash.ts'
|
|
17
21
|
|
|
18
22
|
const NS = 'dsh-project-memory'
|
|
19
23
|
|
|
@@ -22,32 +26,100 @@ export const name = NS
|
|
|
22
26
|
/** Required client services: slots registry, session scopes, commands remote (data 通道). */
|
|
23
27
|
export const inject = ['slots', 'sessions', 'remote', 'remote.commands', 'locale']
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 执行一条命令行,映射成 composer 的 SubmitOutcome。
|
|
31
|
+
* 与 TaskPanel 走同一个 remote.commands.execute 通道(同样的返回信封)。
|
|
32
|
+
* @param commands - ctx.remote.commands
|
|
33
|
+
* @param session - 会话投影(只读 sessionId)
|
|
34
|
+
* @param line - 完整命令行(含前导斜杠)
|
|
35
|
+
* @param attachments - 提交附件(菜单路径恒为空)
|
|
36
|
+
* @returns {kind:'success'|'error', text?}
|
|
37
|
+
*/
|
|
38
|
+
async function runCommand(
|
|
39
|
+
commands: any,
|
|
40
|
+
session: any,
|
|
41
|
+
line: string,
|
|
42
|
+
attachments: readonly unknown[] = [],
|
|
43
|
+
): Promise<{ kind: 'success' | 'error'; text?: string }> {
|
|
44
|
+
const sessionId = session?.sessionId
|
|
45
|
+
if (typeof sessionId !== 'string' || !commands || typeof commands.execute !== 'function') {
|
|
46
|
+
return { kind: 'error', text: `no session / commands service for ${line}` }
|
|
47
|
+
}
|
|
48
|
+
const response: any = await commands.execute(sessionId, line, [...attachments])
|
|
49
|
+
const envelope = response as { ok?: boolean; error?: any; value?: any } | null | undefined
|
|
50
|
+
if (envelope && typeof envelope === 'object' && envelope.ok === false) {
|
|
51
|
+
return { kind: 'error', text: `command.execute failed: ${envelope.error?.code ?? 'unknown'}` }
|
|
52
|
+
}
|
|
53
|
+
const execution = envelope && typeof envelope === 'object' && 'value' in envelope ? envelope.value : envelope
|
|
54
|
+
const result = execution?.result ?? execution
|
|
55
|
+
if (result?.kind === 'error') return { kind: 'error', text: result.text ?? `${line} failed` }
|
|
56
|
+
return { kind: 'success' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 注册自建的 `/` 菜单源(见 slash.ts)。
|
|
61
|
+
*
|
|
62
|
+
* 整段都是**可选增强**:宿主没有 `inputTriggers` 服务、或该服务换了契约时,绝不能因此
|
|
63
|
+
* 让整个 client 插件挂掉——那会连任务面板一起消失。`inputTriggers` 因此不进顶层 `inject`
|
|
64
|
+
* (顶层 inject 未满足时 cordis 根本不会调用 apply),而是走嵌套 inject + 两层 try/catch。
|
|
65
|
+
* @param ctx - client 根上下文
|
|
66
|
+
*/
|
|
67
|
+
function registerSlashSource(ctx: any): void {
|
|
68
|
+
if (typeof ctx?.inject !== 'function') {
|
|
69
|
+
console.warn(`[${NS}] host has no ctx.inject — slash menu group disabled`)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
ctx.inject(['inputTriggers', 'sessions', 'remote.commands'], (scope: any) => {
|
|
74
|
+
try {
|
|
75
|
+
const inputTriggers = scope?.inputTriggers
|
|
76
|
+
if (!inputTriggers || typeof inputTriggers.registerSource !== 'function') {
|
|
77
|
+
console.warn(`[${NS}] host has no inputTriggers.registerSource — slash menu group disabled`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const source = createSlashSource(scope, {
|
|
81
|
+
sessions: scope.sessions,
|
|
82
|
+
run: (session, line, attachments) => runCommand(scope?.remote?.commands, session, line, attachments),
|
|
83
|
+
})
|
|
84
|
+
scope.effect(() => inputTriggers.registerSource(source), 'dsh-project-memory: slash source')
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.warn(`[${NS}] slash source registration failed:`, err)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.warn(`[${NS}] slash source injection failed:`, err)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
25
94
|
export function apply(ctx: any): void {
|
|
26
95
|
const slots = ctx?.slots
|
|
27
96
|
if (!slots || typeof slots.inject !== 'function') {
|
|
28
97
|
console.warn(`[${NS}] host has no slots service — task panel disabled`)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
() => createElement(TaskPanelEntry, { ctx })
|
|
39
|
-
))
|
|
40
|
-
|
|
41
|
-
// 会话内 /tasks、/task、/insight 命令节点:按命令名 key 接管渲染,
|
|
42
|
-
// 替换内置大段文本卡片(文本含 JSON 载荷,只有面板需要它),避免载荷刷屏对话。
|
|
43
|
-
const nodeKeys = ['tasks', 'task', 'insight']
|
|
44
|
-
for (const key of nodeKeys) {
|
|
45
|
-
slots.inject('conversation.chat.commandview', () => slots.register(
|
|
46
|
-
{ name: 'conversation.chat.commandview', key },
|
|
47
|
-
TaskCommandNode
|
|
98
|
+
} else {
|
|
99
|
+
try {
|
|
100
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
101
|
+
{
|
|
102
|
+
name: 'shell.overlay',
|
|
103
|
+
id: 'dsh-project-memory-task-panel',
|
|
104
|
+
order: 100, // 浮层顺序:低于 toast/弹窗即可,越高越靠后渲染
|
|
105
|
+
},
|
|
106
|
+
() => createElement(TaskPanelEntry, { ctx })
|
|
48
107
|
))
|
|
108
|
+
|
|
109
|
+
// 会话内命令节点:按命令名 key 接管渲染,替换内置大段文本卡片
|
|
110
|
+
// (文本含 JSON 载荷,只有面板需要它),避免载荷刷屏对话。
|
|
111
|
+
// 合并后只剩一条用户命令 /tasks(子动词都记在同一个命令名下)。
|
|
112
|
+
const nodeKeys = ['tasks']
|
|
113
|
+
for (const key of nodeKeys) {
|
|
114
|
+
slots.inject('conversation.chat.commandview', () => slots.register(
|
|
115
|
+
{ name: 'conversation.chat.commandview', key },
|
|
116
|
+
TaskCommandNode
|
|
117
|
+
))
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn(`[${NS}] task panel registration failed:`, err)
|
|
49
121
|
}
|
|
50
|
-
} catch (err) {
|
|
51
|
-
console.warn(`[${NS}] task panel registration failed:`, err)
|
|
52
122
|
}
|
|
123
|
+
|
|
124
|
+
registerSlashSource(ctx)
|
|
53
125
|
}
|