@yolk_vat-y/dsh-project-memory 0.5.7 → 0.5.9
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 +188 -0
- package/README.md +139 -193
- package/README.zh-CN.md +136 -192
- package/client/client.js +458 -107
- package/client/client.js.map +1 -1
- package/cordis.patch.yml +7 -1
- package/package.json +4 -6
- package/scripts/bench-synthetic.mjs +3 -3
- package/scripts/bench.mjs +1 -1
- package/src/audit.js +63 -0
- package/src/auto-inject.js +177 -34
- 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 +14 -14
- package/src/client/client.ts +93 -21
- package/src/client/css-modules.d.ts +12 -0
- 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 +15 -3
- package/src/commands/task-actions.js +19 -14
- package/src/commands/tasks.js +4 -22
- package/src/commands/workflow.js +54 -0
- package/src/index.js +51 -9
- package/src/insight-store.js +39 -0
- package/src/lazy.js +22 -80
- package/src/reflection-pipeline.js +2 -1
- package/src/setup/taskbridge.js +19 -12
- package/src/tools/forget.js +2 -2
- package/src/tools/index-doc.js +20 -5
- package/src/tools/index-repo.js +50 -17
- package/src/tools/lesson-tools.js +3 -6
- package/src/tools/query-memory.js +15 -12
- package/src/tools/remember.js +2 -2
- package/src/tools/stats.js +2 -2
- package/src/tools/task-tools.js +6 -20
- package/src/tools/watch-repo.js +17 -10
- package/src/util/fs.js +351 -16
- package/src/util/task-view.js +66 -0
- package/src/util/text.js +14 -0
- package/src/watch.js +46 -7
package/cordis.patch.yml
CHANGED
|
@@ -10,4 +10,10 @@
|
|
|
10
10
|
lazyIndexing: true
|
|
11
11
|
autoIndexOnFirstUse: false
|
|
12
12
|
watch: true
|
|
13
|
-
watchInterval: 15
|
|
13
|
+
watchInterval: 15
|
|
14
|
+
# 危险根护栏(家目录 / 文件系统根 / 系统与包管理器前缀整体扫描会吃满内存)。
|
|
15
|
+
# 自动路径永远不越权;true 只放开**显式**带 root 的工具调用。
|
|
16
|
+
allowUnsafeRoots: false
|
|
17
|
+
# 单次扫描上限;截断会报告,且不会删除未扫到的条目。
|
|
18
|
+
maxScanFiles: 20000
|
|
19
|
+
maxScanDepth: 12
|
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.9",
|
|
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,8 +18,9 @@
|
|
|
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 && node test/task-view.test.mjs && node test/root-guards.test.mjs",
|
|
22
22
|
"eval:injection": "node test/injection-scenarios.test.mjs",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
|
|
23
24
|
"selfcheck:triggers": "node test/injection-scenarios.test.mjs --selfcheck",
|
|
24
25
|
"bench": "node scripts/bench.mjs",
|
|
25
26
|
"bench:synthetic": "node scripts/bench-synthetic.mjs",
|
|
@@ -92,10 +93,7 @@
|
|
|
92
93
|
},
|
|
93
94
|
"client": {
|
|
94
95
|
"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"
|
|
96
|
+
"@deepseek-ai/dsh-client-locale"
|
|
99
97
|
],
|
|
100
98
|
"platform": "web"
|
|
101
99
|
}
|
|
@@ -90,7 +90,7 @@ async function coldIndex(root, config) {
|
|
|
90
90
|
const store = new ProjectMemoryStore(storeDir)
|
|
91
91
|
// 首次 load 会读盘(若已存在),这里传入一个全新实例以贴近真实冷启动
|
|
92
92
|
store.load()
|
|
93
|
-
const files = walkDir(root)
|
|
93
|
+
const { files } = walkDir(root)
|
|
94
94
|
const seen = new Set()
|
|
95
95
|
let updated = 0
|
|
96
96
|
const fileUpdates = []
|
|
@@ -234,7 +234,7 @@ async function main() {
|
|
|
234
234
|
{
|
|
235
235
|
const root = tmpd('pm-sym-')
|
|
236
236
|
generateCorpus(root, FILES)
|
|
237
|
-
const files = walkDir(root).filter((f) => f.endsWith(
|
|
237
|
+
const files = walkDir(root).files.filter((f) => f.endsWith(".js"))
|
|
238
238
|
const times = []
|
|
239
239
|
const n = Math.min(files.length, 500)
|
|
240
240
|
for (let i = 0; i < n; i++) {
|
|
@@ -256,7 +256,7 @@ async function main() {
|
|
|
256
256
|
generateCorpus(root, FILES)
|
|
257
257
|
const { store, report } = await coldIndex(root, config)
|
|
258
258
|
void report
|
|
259
|
-
const files = walkDir(root).filter((f) => f.endsWith(
|
|
259
|
+
const files = walkDir(root).files.filter((f) => f.endsWith(".js"))
|
|
260
260
|
const times = []
|
|
261
261
|
const n = Math.min(files.length, 50)
|
|
262
262
|
for (let i = 0; i < n; i++) {
|
package/scripts/bench.mjs
CHANGED
|
@@ -123,7 +123,7 @@ const kb = (n) => `${(n / 1024).toFixed(1)} KB`
|
|
|
123
123
|
const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`
|
|
124
124
|
|
|
125
125
|
// ---------- 1. 扫描 ----------
|
|
126
|
-
const all = walkDir(root)
|
|
126
|
+
const { files: all } = walkDir(root)
|
|
127
127
|
const targets = []
|
|
128
128
|
let skippedPdf = 0
|
|
129
129
|
for (const abs of all) {
|
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
|
@@ -8,19 +8,52 @@
|
|
|
8
8
|
import { createHash } from 'node:crypto'
|
|
9
9
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
10
10
|
import { ProjectMemoryStore } from './store.js'
|
|
11
|
-
import { memoryRootFor } from './util/fs.js'
|
|
11
|
+
import { hasProjectMarker, isUnsafeRoot, memoryRootFor, resolveProjectMemoryRoot } from './util/fs.js'
|
|
12
|
+
import { textOf } from './util/text.js'
|
|
13
|
+
import { stepContent } from './util/task-view.js'
|
|
12
14
|
import { insightMatchText, normalizedTokenOverlap } from './similarity.js'
|
|
13
15
|
import { BoundedMap, SessionCache } from './util/session-cache.js'
|
|
14
|
-
import { cfgInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
|
|
16
|
+
import { cfgInsight, GlobalStore, defaultGlobalFile, recordHit } from './insight-store.js'
|
|
15
17
|
import { projectTags } from './project-profile.js'
|
|
16
18
|
import { rankEntriesMergedScored } from './util/search.js'
|
|
17
19
|
import { insightToEntry, insightScoringText } from './recall.js'
|
|
18
20
|
import { buildReadinessContext, hintQueryText, idfCoverage, matchTrigger, normalizeTrigger, relativeHits } from './readiness.js'
|
|
19
21
|
import { activityFromCalls } from './ops.js'
|
|
20
|
-
import { appendInjectionAudit, auditRecordFrom, cfgAudit } from './audit.js'
|
|
22
|
+
import { appendInjectionAudit, appendShadowAudit, auditRecordFrom, cfgAudit, cfgShadow, shadowRecordFrom } from './audit.js'
|
|
21
23
|
|
|
22
24
|
export const INJECT_MARK = '[Memory Inject]'
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 本插件在 `message.source` 上声明的生产者 kind。
|
|
28
|
+
*
|
|
29
|
+
* 会话格式 v4 起 `MessageSourceMap` 是「每个生产者声明自己的 kind」的可合并联合类型,
|
|
30
|
+
* **没有** `plugin` 这个兜底 kind:宿主的 `assertV4MessageSources` / `assertV4SourceRowAdmission`
|
|
31
|
+
* 会直接拒绝 `kind === 'plugin'` 的消息(`session-format-v3-to-v4/src/message-sources.ts`),
|
|
32
|
+
* 而拒绝发生在编码落盘那一刻 —— 于是整轮运行失败。
|
|
33
|
+
*
|
|
34
|
+
* 值选 `plugin:<包名>` 是因为这正是宿主自己的 v3→v4 迁移对本插件历史消息的改写结果
|
|
35
|
+
* (`rewritePluginSource`:第三方插件 → `plugin:${plugin}`,并丢弃 `plugin` 字段)。
|
|
36
|
+
* 新写的消息与迁移后的旧消息因此是**同一个生产者身份**,自激闸门(见 isOwnInjection)不需要按新旧分叉。
|
|
37
|
+
*/
|
|
38
|
+
export const SOURCE_KIND = 'plugin:dsh-project-memory'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 这条消息是不是本插件自己注入的?
|
|
42
|
+
*
|
|
43
|
+
* 三种写法都要认,因为同一条历史消息在不同阶段形状不同:
|
|
44
|
+
* - `plugin:dsh-project-memory` —— 现在写的,也是 v3→v4 迁移改写老消息的结果;
|
|
45
|
+
* - `project-memory` —— 保留的别名:将来若再换 kind,自激闸门不会因此失效;
|
|
46
|
+
* - `plugin: 'dsh-project-memory'` —— 迁移前内存里尚未落盘的旧形状(老宿主 / 测试夹具)。
|
|
47
|
+
* @param {object|undefined|null} source - message.source
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function isOwnInjection(source) {
|
|
51
|
+
if (!source || typeof source !== 'object') return false
|
|
52
|
+
return source.kind === SOURCE_KIND
|
|
53
|
+
|| source.kind === 'project-memory'
|
|
54
|
+
|| source.plugin === 'dsh-project-memory'
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
/**
|
|
25
58
|
* 注入正文的最小可用长度:预算塞不下这么多就宁可不注入(记 dropped)。
|
|
26
59
|
* 与其输出 `- [ins_xxx] procedure ` 这种 stub,不如保持沉默——stub 只消耗 token 不传递信息。
|
|
@@ -67,9 +100,13 @@ export function cfgEngine(config) {
|
|
|
67
100
|
// 每会话条目注入的上限(条数 / 字符):预算只能是上限,不是目标。
|
|
68
101
|
maxItemsPerSession: typeof c.maxItemsPerSession === 'number' && c.maxItemsPerSession >= 0 ? c.maxItemsPerSession : 12,
|
|
69
102
|
maxItemCharsPerSession: typeof c.maxItemCharsPerSession === 'number' && c.maxItemCharsPerSession >= 0 ? c.maxItemCharsPerSession : 4000,
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
103
|
+
// 提示通道的**绝对**覆盖底线(IDF 加权覆盖率):相对阈值分不出"有信号"和"矮子里拔将军",
|
|
104
|
+
// 只有归一在 [0,1]、有真零点才谈得上下限。0.30 时的实测反例:真实 store(43 条同源洞察)上,
|
|
105
|
+
// 对照组场景「改 pptx 时间戳」以 cov 0.32~0.35 注入了 3 条无关条目——语料同源时共享词多、
|
|
106
|
+
// IDF 分辨力被拉平,"最不坏的一条"就能过 0.30。0.45 落在实测分布的空隙里(假阳性 ≤0.35、
|
|
107
|
+
// 下一个真命中 ≥0.49),合成标注集在 0.6 以前 precision/recall 也仍是 1.00。
|
|
108
|
+
// 用 `--hint-cov` 在真实 store 上重放可复核(见 test/injection-scenarios.test.mjs)。
|
|
109
|
+
hintMinCoverage: typeof c.hintMinCoverage === 'number' && c.hintMinCoverage >= 0 ? c.hintMinCoverage : 0.45,
|
|
73
110
|
// 提示通道还要求至少这么多个共同词:单个通用词("插件")也能拿到 coverage=1.00。
|
|
74
111
|
hintMinMatched: typeof c.hintMinMatched === 'number' && c.hintMinMatched >= 0 ? c.hintMinMatched : 2,
|
|
75
112
|
// 通道级沉默:查询里能在语料中找到对应的词占比低于这个值时,整条提示通道本轮不出声。
|
|
@@ -78,14 +115,6 @@ export function cfgEngine(config) {
|
|
|
78
115
|
}
|
|
79
116
|
}
|
|
80
117
|
|
|
81
|
-
function textOf(message) {
|
|
82
|
-
const blocks = (message && message.content) || []
|
|
83
|
-
return blocks
|
|
84
|
-
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
85
|
-
.map((b) => b.text)
|
|
86
|
-
.join('\n')
|
|
87
|
-
}
|
|
88
|
-
|
|
89
118
|
export function lastUserText(messages) {
|
|
90
119
|
if (!Array.isArray(messages)) return ''
|
|
91
120
|
let fallback = ''
|
|
@@ -98,8 +127,8 @@ export function lastUserText(messages) {
|
|
|
98
127
|
// 上一步的注入块会变成这一步的就绪查询(自激:拿自己注入的内容再检索一遍)。
|
|
99
128
|
if (m.source && m.source.kind === 'user') return t.trim()
|
|
100
129
|
// 本插件注入的块同样是 role=user。无 source 的老宿主下若把它当兜底查询,
|
|
101
|
-
// 就成了"拿自己上一步注入的内容再检索一遍"的自激——正是
|
|
102
|
-
if (m.source
|
|
130
|
+
// 就成了"拿自己上一步注入的内容再检索一遍"的自激——正是 isOwnInjection 这道闸要防的。
|
|
131
|
+
if (isOwnInjection(m.source)) continue
|
|
103
132
|
if (!fallback) fallback = t.trim() // 无 source 的消息(老宿主 / 测试)兜底
|
|
104
133
|
}
|
|
105
134
|
return fallback
|
|
@@ -146,7 +175,7 @@ export function shouldEchoTaskCard(task, cfg) {
|
|
|
146
175
|
export function buildEntryContent(task, cfg, { withEdited = true } = {}) {
|
|
147
176
|
if (!task) return ''
|
|
148
177
|
const c = cfg || {}
|
|
149
|
-
const steps = (task.steps || []).map((s) => (
|
|
178
|
+
const steps = (task.steps || []).map((s) => stepContent(s).slice(0, 80))
|
|
150
179
|
const progress = steps.length ? `${steps.length} 步` : ''
|
|
151
180
|
const card = [
|
|
152
181
|
`任务: ${task.title || '(untitled)'}`,
|
|
@@ -226,7 +255,11 @@ function collectTriggered(cands, ctx) {
|
|
|
226
255
|
*/
|
|
227
256
|
function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
228
257
|
const hints = []
|
|
229
|
-
|
|
258
|
+
// 影子记录用:本步**全部**被评过分的候选 + 特征 + 判据结果。与 hints/dropped 分开算——
|
|
259
|
+
// 现有两类记录都只覆盖"过得了相对阈值的那部分",恰恰缺了"因为没到阈值而从未被考虑"的候选,
|
|
260
|
+
// 而那正是离线重放阈值时需要的那批。这里只做加法,不动任何现有判据。
|
|
261
|
+
const candidates = []
|
|
262
|
+
if (!cands.length) return { hints, candidates }
|
|
230
263
|
if (typeof cfg.relevanceMin === 'number') {
|
|
231
264
|
// 兼容:显式 relevanceMin → 旧的绝对 overlap 判据(老 profile 行为不变)
|
|
232
265
|
for (const it of cands) {
|
|
@@ -234,10 +267,10 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
234
267
|
if (score >= cfg.relevanceMin) hints.push({ it, score, why: `overlap:${score.toFixed(3)}` })
|
|
235
268
|
}
|
|
236
269
|
hints.sort((a, b) => b.score - a.score)
|
|
237
|
-
return hints
|
|
270
|
+
return { hints, candidates } // 旧判据路径不产出影子候选(已废弃,无重放价值)
|
|
238
271
|
}
|
|
239
272
|
const q = hintQueryText(hintQuery)
|
|
240
|
-
if (!q) return hints
|
|
273
|
+
if (!q) return { hints, candidates }
|
|
241
274
|
const byId = new Map(cands.map((it) => [it.id, it]))
|
|
242
275
|
// 覆盖率语料必须与 BM25 排序同源(insightScoringText):否则"只在 fix/solution 里有匹配"
|
|
243
276
|
// 的查询词 df=0 → supportRatio=0 → 整条提示通道沉默,而排序明明给了它高分。
|
|
@@ -272,7 +305,36 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
272
305
|
}
|
|
273
306
|
hints.push({ it, score: r.score, why: `relative:${(r.score / (scored[0].score || 1)).toFixed(2)} cov:${ev.coverage.toFixed(2)}/${ev.matched}` })
|
|
274
307
|
}
|
|
275
|
-
|
|
308
|
+
|
|
309
|
+
// ---- 影子候选(只读、无副作用):重放一遍**全部**被评分的条目,标出它卡在哪一关 ----
|
|
310
|
+
// 顺序与真实判据一致(support → coverage → thin → relative),最后 'cand' = 过了全部门槛
|
|
311
|
+
// (注意 'cand' 不等于"真的注入了":还要过去重/预算/冷却,那些写在同一行的 injected/silence)。
|
|
312
|
+
{
|
|
313
|
+
const top = scored[0]?.score || 0
|
|
314
|
+
const ratio = typeof cfg.signalMinRatio === 'number' && Number.isFinite(cfg.signalMinRatio) ? cfg.signalMinRatio : 0.5
|
|
315
|
+
for (const r of scored) {
|
|
316
|
+
const it = byId.get(r.entry.insightId)
|
|
317
|
+
if (!it || !Number.isFinite(r.score) || r.score <= 0) continue
|
|
318
|
+
const ev = coverage.get(it.id) || { coverage: 0, matched: 0, supported: 0, terms: 0 }
|
|
319
|
+
const needMatched = Math.min(typeof cfg.hintMinMatched === 'number' ? cfg.hintMinMatched : 2, ev.terms)
|
|
320
|
+
let decision = 'cand'
|
|
321
|
+
if (channelThin) decision = 'support'
|
|
322
|
+
else if (typeof cfg.hintMinCoverage === 'number' && ev.coverage < cfg.hintMinCoverage) decision = 'coverage'
|
|
323
|
+
else if (ev.matched < needMatched) decision = 'thin'
|
|
324
|
+
else if (r.score < top * Math.max(0, Math.min(1, ratio))) decision = 'relative'
|
|
325
|
+
candidates.push({
|
|
326
|
+
id: it.id,
|
|
327
|
+
channel: 'hint',
|
|
328
|
+
rel: Number((r.score / (top || 1)).toFixed(3)),
|
|
329
|
+
coverage: Number(ev.coverage.toFixed(3)),
|
|
330
|
+
matched: ev.matched,
|
|
331
|
+
support: ev.supported,
|
|
332
|
+
terms: ev.terms,
|
|
333
|
+
decision,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { hints, candidates }
|
|
276
338
|
}
|
|
277
339
|
|
|
278
340
|
/**
|
|
@@ -317,7 +379,7 @@ export function buildInjection(opts) {
|
|
|
317
379
|
// procedure 不进提示通道:它要过 trigger.scope(见 scoreHints 注释)。
|
|
318
380
|
const consumed = new Set(triggered.map((t) => t.it.id))
|
|
319
381
|
const hintCands = skipItems ? [] : cands.filter((it) => !consumed.has(it.id) && it.kind !== 'procedure')
|
|
320
|
-
const hints = scoreHints({
|
|
382
|
+
const { hints, candidates } = scoreHints({
|
|
321
383
|
cands: hintCands,
|
|
322
384
|
query,
|
|
323
385
|
humanText: ctx.humanText,
|
|
@@ -368,7 +430,7 @@ export function buildInjection(opts) {
|
|
|
368
430
|
// 整块只受单轮预算(maxTokens)约束;会话条目额度已在 place() 里单独扣过。
|
|
369
431
|
const clamp = (t) => (t.length > stepBudget ? `${t.slice(0, stepBudget)}\n…(截断)` : t)
|
|
370
432
|
const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
|
|
371
|
-
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, dedupeText, itemChars: itemUsed }
|
|
433
|
+
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, candidates, dedupeText, itemChars: itemUsed }
|
|
372
434
|
}
|
|
373
435
|
|
|
374
436
|
// 每会话状态的会话数上限:六张表共用(旧实现把同一个 200 在五处各写一遍)。
|
|
@@ -404,6 +466,8 @@ class InjectionSessions {
|
|
|
404
466
|
// 反应窗口:本会话最近观察到的 tool/call(参数里有 git commit / npm publish / 改动的路径)。
|
|
405
467
|
// 宿主没有"工具执行前拦截"钩子,所以这是 pre-step 之外唯一能拿到的动作事实。
|
|
406
468
|
this.observed = cache(() => [])
|
|
469
|
+
// 本会话是否已通告"记忆根是推定的"(会话工作目录,无项目标记)。状态声明只发一次。
|
|
470
|
+
this.rootNoticed = cache(() => false)
|
|
407
471
|
this._all = Object.values(this)
|
|
408
472
|
}
|
|
409
473
|
|
|
@@ -439,6 +503,7 @@ export function installAutoInject(ctx, config) {
|
|
|
439
503
|
const cfg = cfgEngine(config)
|
|
440
504
|
// 审计配置同 cfg:安装时解析一次(与 autoContext 其余开关一致)。
|
|
441
505
|
const audit = cfgAudit(config)
|
|
506
|
+
const shadow = cfgShadow(config)
|
|
442
507
|
const sessions = new InjectionSessions()
|
|
443
508
|
|
|
444
509
|
installCallObserver(ctx, sessions)
|
|
@@ -455,7 +520,7 @@ export function installAutoInject(ctx, config) {
|
|
|
455
520
|
if (!decision) return fallback()
|
|
456
521
|
if (decision.kind !== 'enter') return decision
|
|
457
522
|
try {
|
|
458
|
-
return (await injectForStep({ payload, decision, config, cfg, audit, sessions })) || decision
|
|
523
|
+
return (await injectForStep({ payload, decision, config, cfg, audit, shadow, sessions })) || decision
|
|
459
524
|
} catch (err) {
|
|
460
525
|
console.error(`[dsh-project-memory] auto-inject skipped: ${err?.message || err}`)
|
|
461
526
|
return decision
|
|
@@ -463,15 +528,38 @@ export function installAutoInject(ctx, config) {
|
|
|
463
528
|
})
|
|
464
529
|
}
|
|
465
530
|
|
|
531
|
+
/** 已就「会话根不安全」提示过的目录:每个进程、每个根只提示一次。 */
|
|
532
|
+
const warnedUnsafeRoots = new Set()
|
|
533
|
+
|
|
534
|
+
function warnUnsafeSessionRoot(root) {
|
|
535
|
+
if (warnedUnsafeRoots.has(root)) return
|
|
536
|
+
warnedUnsafeRoots.add(root)
|
|
537
|
+
console.error(
|
|
538
|
+
`[dsh-project-memory] session cwd ${root} is not a project root (home / system / package-manager prefix); ` +
|
|
539
|
+
'project memory and its audit logs are disabled for this session. Start dsh inside a project directory to enable them.',
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
466
543
|
/**
|
|
467
544
|
* 一个 pre-step 的注入决策:解析会话 → 排程 → 必要时构造消息。
|
|
468
545
|
* 返回 null 表示本步无事可做(交回原决策)。
|
|
469
546
|
*/
|
|
470
|
-
async function injectForStep({ payload, decision, config, cfg, audit, sessions }) {
|
|
547
|
+
async function injectForStep({ payload, decision, config, cfg, audit, shadow, sessions }) {
|
|
471
548
|
const session = payload && payload.agent && payload.agent.session
|
|
472
|
-
const
|
|
549
|
+
const cwd = session && session.header && session.header.cwd
|
|
473
550
|
const sessionId = session && session.id
|
|
474
|
-
if (!
|
|
551
|
+
if (!cwd || !sessionId) return null
|
|
552
|
+
// 会话 cwd 是家目录/系统目录时整条链路停在这里:不读 store(历史遗留的超大 store
|
|
553
|
+
// 光是 load() 就能 OOM),也不写审计——`appendShadowAudit` 里的 mkdirSync 正是把
|
|
554
|
+
// `.dsh-project-memory` 造进家目录、进而触发"目录自标记 → 家目录被锁成项目"的那一步。
|
|
555
|
+
if (isUnsafeRoot(cwd)) {
|
|
556
|
+
warnUnsafeSessionRoot(cwd)
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
// 记忆根与懒索引/工具侧同一套解析:项目标记优先,其次是安全的会话工作目录。
|
|
560
|
+
// 少了这一步,同一个会话的审计日志与懒索引会落在两个不同的 store 里。
|
|
561
|
+
const root = resolveProjectMemoryRoot(cwd)
|
|
562
|
+
if (!root) return null
|
|
475
563
|
|
|
476
564
|
const query = lastUserText(decision.messages)
|
|
477
565
|
const memoryRoot = memoryRootFor(root, config.memoryDir)
|
|
@@ -518,8 +606,29 @@ async function injectForStep({ payload, decision, config, cfg, audit, sessions }
|
|
|
518
606
|
// 留痕仍然记账(去重 + 上限),只是默认不外泄到用户的终端。
|
|
519
607
|
recordDropped({ sessions, sessionId, dropped: built.dropped, budgetLog: cfg.budgetLog })
|
|
520
608
|
|
|
521
|
-
|
|
522
|
-
|
|
609
|
+
// 影子记录:**每步**都写一行(含零注入的静默步与对照组),带全部候选的判据特征。
|
|
610
|
+
// 主审计只在真的注入时写,静默步零痕迹 → 日志无法离线重放"换个阈值会怎样",
|
|
611
|
+
// 也攒不出训练样本。只写盘、不进 prompt、不花 token。
|
|
612
|
+
appendShadowAudit(memoryRoot, shadowRecordFrom({
|
|
613
|
+
sessionId,
|
|
614
|
+
root,
|
|
615
|
+
step: stepNo,
|
|
616
|
+
query: query || '',
|
|
617
|
+
ops: readiness.ops,
|
|
618
|
+
writes: readiness.targets,
|
|
619
|
+
reasons: built.reasons,
|
|
620
|
+
candidates: built.candidates,
|
|
621
|
+
silence: silence.reason,
|
|
622
|
+
}), shadow)
|
|
623
|
+
|
|
624
|
+
// 记忆根通告:根是**推定**的(会话工作目录,且该目录没有任何项目标记)时,
|
|
625
|
+
// 第一次注入前告诉模型"记忆现在存在哪、怎么改"。它是状态声明(与常驻任务卡同类),
|
|
626
|
+
// 只发一次、不占条目额度;否则模型只看到一条来自某处的记忆,却不知道那个根是怎么来的。
|
|
627
|
+
const notice = rootNotice({ root, sessionId, sessions, config })
|
|
628
|
+
|
|
629
|
+
const fp = built.text ? fingerprint(built.dedupeText ?? built.text) : fingerprint(notice || '')
|
|
630
|
+
const injectedText = notice ? (built.text ? `${notice}\n${built.text}` : notice) : built.text
|
|
631
|
+
if (!injectedText || fp === sessions.lastText.peek(sessionId)) return null
|
|
523
632
|
sessions.lastText.set(sessionId, fp)
|
|
524
633
|
|
|
525
634
|
// 只记真的进了上下文的那几条:dropped 的没被看到,不能记账(否则以后永远不再注入)。
|
|
@@ -534,12 +643,25 @@ async function injectForStep({ payload, decision, config, cfg, audit, sessions }
|
|
|
534
643
|
quota.lastStep = stepNo
|
|
535
644
|
}
|
|
536
645
|
|
|
646
|
+
// 使用记账:注入进上下文 = 这条记忆被用到了。必须在这里显式落盘——注入路径不会走到
|
|
647
|
+
// 任何其它 save(),只标脏等于进程退出就丢。记账失败绝不影响注入(与审计同约定)。
|
|
648
|
+
if (built.reasons.length) {
|
|
649
|
+
try {
|
|
650
|
+
if (recordHit({ store, globalStore, ids: built.reasons.map((r) => r.id) })) {
|
|
651
|
+
store.save()
|
|
652
|
+
globalStore.commit()
|
|
653
|
+
}
|
|
654
|
+
} catch {
|
|
655
|
+
/* ignore:记账是旁路,不能拖累注入 */
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
537
659
|
// S0 观测:只记真的进了上下文的那一次(dropped 单独出现不写,否则每步刷屏)。
|
|
538
660
|
appendInjectionAudit(memoryRoot, auditRecordFrom({
|
|
539
661
|
sessionId,
|
|
540
662
|
root,
|
|
541
663
|
step: stepNo,
|
|
542
|
-
text:
|
|
664
|
+
text: injectedText,
|
|
543
665
|
labels: built.labels,
|
|
544
666
|
reasons: built.reasons,
|
|
545
667
|
dropped: built.dropped,
|
|
@@ -547,7 +669,29 @@ async function injectForStep({ payload, decision, config, cfg, audit, sessions }
|
|
|
547
669
|
silence: silence.reason,
|
|
548
670
|
}), audit)
|
|
549
671
|
|
|
550
|
-
return { ...decision, messages: [...decision.messages, injectionMessage(
|
|
672
|
+
return { ...decision, messages: [...decision.messages, injectionMessage(injectedText)] }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* 「记忆根是推定的」一次性通告。
|
|
677
|
+
*
|
|
678
|
+
* 触发条件:根是**安全目录但自身没有任何项目标记**(即根来自会话工作目录,而不是
|
|
679
|
+
* `.git`/`package.json` 这类声明)。此时插件会按 cwd 建 `.dsh-project-memory`——必须
|
|
680
|
+
* 让模型知道这件事,否则它只会看到记忆来自一个自己没指定过的位置,而纠正的成本随
|
|
681
|
+
* 会话推进快速上升。通告只发一次,且与常驻任务卡同类:是状态声明,不占条目额度。
|
|
682
|
+
*
|
|
683
|
+
* 关掉它:`autoContext.rootNotice: false`。
|
|
684
|
+
*/
|
|
685
|
+
function rootNotice({ root, sessionId, sessions, config }) {
|
|
686
|
+
if (config?.autoContext?.rootNotice === false) return null
|
|
687
|
+
if (sessions.rootNoticed.peek(sessionId)) return null
|
|
688
|
+
if (hasProjectMarker(root)) return null
|
|
689
|
+
sessions.rootNoticed.set(sessionId, true)
|
|
690
|
+
return (
|
|
691
|
+
`memory root: ${root} (inferred from the session working directory; no project marker found). ` +
|
|
692
|
+
'If project memory should live elsewhere, pass `root: <dir>` to index_repo / watch_repo / remember / query_memory, ' +
|
|
693
|
+
'or restart dsh inside the project directory.'
|
|
694
|
+
)
|
|
551
695
|
}
|
|
552
696
|
|
|
553
697
|
/**
|
|
@@ -594,10 +738,9 @@ function injectionMessage(text) {
|
|
|
594
738
|
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${text}` }],
|
|
595
739
|
// 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
|
|
596
740
|
// 宿主 ContextFormed 是判别联合:snapshot 必须带 sections(notice 才需要 summary)。
|
|
597
|
-
//
|
|
741
|
+
// kind 必须是生产者自有的(v4 没有 'plugin' 兜底 kind),见 SOURCE_KIND 的注释。
|
|
598
742
|
source: {
|
|
599
|
-
kind:
|
|
600
|
-
plugin: 'dsh-project-memory',
|
|
743
|
+
kind: SOURCE_KIND,
|
|
601
744
|
form: 'snapshot',
|
|
602
745
|
sections: [{ name: 'project-memory', text }],
|
|
603
746
|
},
|
|
@@ -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'
|