@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +139 -193
  3. package/README.zh-CN.md +136 -192
  4. package/client/client.js +458 -107
  5. package/client/client.js.map +1 -1
  6. package/cordis.patch.yml +7 -1
  7. package/package.json +4 -6
  8. package/scripts/bench-synthetic.mjs +3 -3
  9. package/scripts/bench.mjs +1 -1
  10. package/src/audit.js +63 -0
  11. package/src/auto-inject.js +177 -34
  12. package/src/client/MemoryView.tsx +12 -4
  13. package/src/client/TaskCommandNode.tsx +1 -1
  14. package/src/client/TaskComponents.tsx +3 -2
  15. package/src/client/TaskPanel.tsx +14 -14
  16. package/src/client/client.ts +93 -21
  17. package/src/client/css-modules.d.ts +12 -0
  18. package/src/client/icons.ts +63 -0
  19. package/src/client/locales.ts +11 -0
  20. package/src/client/session-id.js +88 -0
  21. package/src/client/slash.ts +184 -0
  22. package/src/commands/insight-actions.js +15 -3
  23. package/src/commands/task-actions.js +19 -14
  24. package/src/commands/tasks.js +4 -22
  25. package/src/commands/workflow.js +54 -0
  26. package/src/index.js +51 -9
  27. package/src/insight-store.js +39 -0
  28. package/src/lazy.js +22 -80
  29. package/src/reflection-pipeline.js +2 -1
  30. package/src/setup/taskbridge.js +19 -12
  31. package/src/tools/forget.js +2 -2
  32. package/src/tools/index-doc.js +20 -5
  33. package/src/tools/index-repo.js +50 -17
  34. package/src/tools/lesson-tools.js +3 -6
  35. package/src/tools/query-memory.js +15 -12
  36. package/src/tools/remember.js +2 -2
  37. package/src/tools/stats.js +2 -2
  38. package/src/tools/task-tools.js +6 -20
  39. package/src/tools/watch-repo.js +17 -10
  40. package/src/util/fs.js +351 -16
  41. package/src/util/task-view.js +66 -0
  42. package/src/util/text.js +14 -0
  43. 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.7",
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('.js'))
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('.js'))
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)。
@@ -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
- // 提示通道的**绝对**下限(IDF 加权覆盖率):相对阈值分不出"有信号"和"矮子里拔将军"。
71
- // null = 关闭(回到只有相对阈值的老行为)。
72
- hintMinCoverage: typeof c.hintMinCoverage === 'number' && c.hintMinCoverage >= 0 ? c.hintMinCoverage : 0.3,
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
- // 就成了"拿自己上一步注入的内容再检索一遍"的自激——正是 kind==='user' 这道闸要防的。
102
- if (m.source && m.source.plugin === 'dsh-project-memory') continue
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) => (typeof s === 'string' ? s : s.content || s.text || '').slice(0, 80))
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
- if (!cands.length) return hints
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
- return hints
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 root = session && session.header && session.header.cwd
549
+ const cwd = session && session.header && session.header.cwd
473
550
  const sessionId = session && session.id
474
- if (!root || !sessionId) return null
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
- const fp = fingerprint(built.dedupeText ?? built.text)
522
- if (!built.text || fp === sessions.lastText.peek(sessionId)) return null
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: built.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(built.text)] }
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
- // 通道不变(仍走 agent/pre-step 追加 user 消息),只修语义。
741
+ // kind 必须是生产者自有的(v4 没有 'plugin' 兜底 kind),见 SOURCE_KIND 的注释。
598
742
  source: {
599
- kind: 'plugin',
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 '@deepseek-ai/dsh-client-ui-primitives'
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 '@deepseek-ai/dsh-client-ui-primitives'
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'