@yolk_vat-y/dsh-project-memory 0.5.6 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +37 -34
  3. package/README.zh-CN.md +35 -34
  4. package/client/client.js +458 -107
  5. package/client/client.js.map +1 -1
  6. package/package.json +3 -6
  7. package/src/audit.js +63 -0
  8. package/src/auto-inject.js +447 -286
  9. package/src/chunker.js +14 -6
  10. package/src/client/MemoryView.tsx +12 -4
  11. package/src/client/TaskCommandNode.tsx +1 -1
  12. package/src/client/TaskComponents.tsx +3 -2
  13. package/src/client/TaskPanel.tsx +13 -13
  14. package/src/client/client.ts +93 -21
  15. package/src/client/icons.ts +63 -0
  16. package/src/client/locales.ts +11 -0
  17. package/src/client/session-id.js +88 -0
  18. package/src/client/slash.ts +184 -0
  19. package/src/commands/insight-actions.js +31 -16
  20. package/src/commands/invocation.js +36 -0
  21. package/src/commands/task-actions.js +36 -40
  22. package/src/commands/tasks.js +18 -13
  23. package/src/commands/workflow.js +54 -0
  24. package/src/enhancer.js +11 -2
  25. package/src/index-pipeline.js +119 -0
  26. package/src/index.js +24 -8
  27. package/src/insight-store.js +96 -28
  28. package/src/lazy.js +15 -46
  29. package/src/link.js +10 -1
  30. package/src/parsers/pdfjs-parser.js +2 -4
  31. package/src/project-profile.js +12 -5
  32. package/src/readiness.js +27 -3
  33. package/src/recall.js +28 -8
  34. package/src/setup/taskbridge.js +11 -9
  35. package/src/store.js +56 -17
  36. package/src/symbols.js +58 -19
  37. package/src/tools/forget.js +2 -1
  38. package/src/tools/index-doc.js +4 -1
  39. package/src/tools/index-repo.js +33 -86
  40. package/src/tools/lesson-tools.js +2 -1
  41. package/src/tools/query-memory.js +29 -4
  42. package/src/tools/remember.js +3 -1
  43. package/src/tools/task-tools.js +4 -1
  44. package/src/tools/watch-repo.js +9 -5
  45. package/src/util/fs.js +14 -0
  46. package/src/util/session-cache.js +72 -0
  47. package/src/watch.js +39 -81
package/src/recall.js CHANGED
@@ -101,6 +101,21 @@ export function insightToEntry(ins) {
101
101
  }
102
102
  }
103
103
 
104
+ /**
105
+ * 一条 insight 的**打分文本**(BM25 与 IDF 覆盖率必须用同一份)。
106
+ *
107
+ * 为什么要有这个函数:提示通道的"这条查询离语料有多远"(`idfCoverage` 的 df)曾经用
108
+ * `insightMatchText` 计算,而排序用 `insightToEntry`(含 fix/solution/reason/steps)。
109
+ * 于是"只在解药里有匹配"的查询词 df=0 → supportRatio=0 → **整条提示通道本轮沉默**,
110
+ * 而排序明明给了它高分。两份文本永远来自同一个 entry,才不会再漂移。
111
+ * @param {object} ins
112
+ * @returns {string}
113
+ */
114
+ export function insightScoringText(ins) {
115
+ const e = insightToEntry(ins)
116
+ return [e.title, e.summary, e.terms, ...(e.keywords || [])].filter(Boolean).join('\n')
117
+ }
118
+
104
119
  /**
105
120
  * 召回可见的 insight 集合:作用域可见性跟随会话绑定。
106
121
  * - project / global:始终可见(归档除外);
@@ -127,6 +142,9 @@ export function visibleInsights({ store = null, globalStore = null, boundTaskId
127
142
  const task = store.getTask(boundTaskId)
128
143
  for (const it of (task && task.insights) || []) {
129
144
  if (!it || it.archived) continue
145
+ // 任务级也要挡草稿:reflection 写的是 draft:true,未确认前不该被 query_memory 召回
146
+ // (project/global 一直有这道闸,任务级漏了)。
147
+ if (it.draft === true) continue
130
148
  out.push({ ...it, scope: it.scope || 'task' })
131
149
  }
132
150
  }
@@ -163,15 +181,17 @@ export function recallItems(opts = {}) {
163
181
  if (want.has('doc') || want.has('symbol')) {
164
182
  const pool = (entries || (store && typeof store.allEntries === 'function' ? store.allEntries() : []) || [])
165
183
  .filter((e) => e && (e.type === 'doc' || e.type === 'symbol') && want.has(e.type))
166
- const scored = rankEntriesStreaming(pool, qs, idf, limit)
167
- const byLayer = new Map([['doc', []], ['symbol', []]])
168
- for (const { entry, score } of scored) {
169
- const prior = entry.type === 'doc' ? normativePrior(entry) : LAYER_PRIORS.symbol
170
- byLayer.get(entry.type).push({ item: entry, score, prior, weightedScore: score * prior })
171
- }
184
+ // 每层各自 top-k(不变量 #2):一次跨类型取 top-k 会让文档把符号挤出结果——
185
+ // 20 条命中文档 + 1 条命中符号、limit 8 时符号层直接消失。分类型各取一次再分桶。
172
186
  for (const layer of ['doc', 'symbol']) {
173
- const hits = byLayer.get(layer)
174
- if (!hits.length) continue
187
+ const layerPool = pool.filter((e) => e.type === layer)
188
+ if (!layerPool.length) continue
189
+ const scored = rankEntriesStreaming(layerPool, qs, idf, limit)
190
+ if (!scored.length) continue
191
+ const hits = scored.map(({ entry, score }) => {
192
+ const prior = layer === 'doc' ? normativePrior(entry) : LAYER_PRIORS.symbol
193
+ return { item: entry, score, prior, weightedScore: score * prior }
194
+ })
175
195
  hits.sort((a, b) => b.weightedScore - a.weightedScore)
176
196
  const top = hits[0].weightedScore || 1
177
197
  buckets.push({
@@ -2,7 +2,7 @@
2
2
  // 事实依据:dsh session/event 签名 (session, event);todo/write data.todos;
3
3
  // tool/call data.arguments 为 JSON 字符串;fs 工具名 read/write/edit/read_image,参数 file_path。
4
4
  import path from 'node:path'
5
- import { createHash } from 'node:crypto'
5
+ import { createHash, randomUUID } from 'node:crypto'
6
6
  import { memoryRootFor } from '../util/fs.js'
7
7
  import { findProjectRoot } from '../lazy.js'
8
8
  import { ProjectMemoryStore } from '../store.js'
@@ -27,7 +27,9 @@ export function slugifyTitle(text) {
27
27
 
28
28
  export function genTaskId(projectRoot, title) {
29
29
  const slug = slugifyTitle(title) || 'task'
30
- return `tsk_${hash8(projectRoot)}_${slug}_${Date.now()}`
30
+ // 毫秒不是唯一性保证:两个会话在同一毫秒建同名任务会拿到同一个 id,
31
+ // addTask 变成"同一 id 两次",两个会话的绑定互相串台。补 6 位随机后缀。
32
+ return `tsk_${hash8(projectRoot)}_${slug}_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`
31
33
  }
32
34
 
33
35
  /** 项目根推导:findProjectRoot 期望文件路径,传目录会从父级起跳,故用目录内探针路径。 */
@@ -138,13 +140,7 @@ export function hotSortFiles(files, meta) {
138
140
  /** 触碰文件后更新元数据并重排 task.files(写 vs 读分别记时间/次数)。 */
139
141
  export function touchTaskFile(task, rel, kind, now) {
140
142
  task.fileMeta = task.fileMeta || {}
141
- if (!task.files.includes(rel)) {
142
- task.files.push(rel)
143
- if (task.files.length > MAX_FILES_PER_TASK) {
144
- const dropped = task.files.shift()
145
- delete task.fileMeta[dropped]
146
- }
147
- }
143
+ if (!task.files.includes(rel)) task.files.push(rel)
148
144
  const m = task.fileMeta[rel] || {}
149
145
  m.n = (m.n || 0) + 1 // 兼容旧字段:读+写总数(保留,避免老任务语义漂移)
150
146
  // 读/写分计数(先只采集,不消费 —— 排序与注入仍只用 lastWriteAt/lastReadAt)
@@ -157,6 +153,12 @@ export function touchTaskFile(task, rel, kind, now) {
157
153
  m.lastReadAt = now
158
154
  task.fileMeta[rel] = m
159
155
  task.files = hotSortFiles(task.files, task.fileMeta)
156
+ // 超限丢最冷的:列表已按热度降序,末尾即最冷。旧代码在**重排前** shift(),
157
+ // 而那时 files 本身就是热序,shift 恰好把最热的那个文件(刚写的)丢掉、留下最冷的。
158
+ while (task.files.length > MAX_FILES_PER_TASK) {
159
+ const dropped = task.files.pop()
160
+ delete task.fileMeta[dropped]
161
+ }
160
162
  }
161
163
 
162
164
  /**
package/src/store.js CHANGED
@@ -17,6 +17,14 @@ const SHARDS_DIR = 'shards'
17
17
  const storeCache = new Map()
18
18
  const STORE_CACHE_MAX = 32
19
19
 
20
+ /** 已就"无法迁移的旧 store"告警过的目录:避免每次 load() 都刷一行。 */
21
+ const migrationWarned = new Set()
22
+
23
+ /** 纯对象判定(排除 null / 数组):磁盘读入的 JSON 形状校验统一走它。 */
24
+ function isRecord(value) {
25
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
26
+ }
27
+
20
28
  function loadJson(filePath, fallback) {
21
29
  let raw
22
30
  try {
@@ -82,7 +90,9 @@ export class ProjectMemoryStore {
82
90
  load() {
83
91
  const key = path.resolve(this.dir)
84
92
  const hot = storeCache.get(key)
85
- if (hot && hot !== this) return hot
93
+ // 缓存命中即返回:`hot === this` 时再读一遍盘会静默丢弃本实例尚未 save() 的变更
94
+ // (_loadSharded/_loadInsights 会重新赋值 experience/tasks/insights…)。
95
+ if (hot) return hot
86
96
  this._migrateLegacyIfNeeded()
87
97
  this._loadSharded()
88
98
  this._loadInsights()
@@ -114,9 +124,34 @@ export class ProjectMemoryStore {
114
124
  }
115
125
  const legacyEntriesPath = path.join(this.dir, ENTRIES_FILE)
116
126
  if (!existsSafe(legacyEntriesPath)) return
117
- const index = loadJson(path.join(this.dir, INDEX_FILE), {})
118
- const files = index.files || {}
119
- const entries = loadJson(legacyEntriesPath, {})
127
+ const legacyIndexPath = path.join(this.dir, INDEX_FILE)
128
+ // 先读两份旧文件:损坏的那份会在这里被 loadJson 备份为 .corrupt(保留现场)。
129
+ const index = loadJson(legacyIndexPath, null)
130
+ const entries = loadJson(legacyEntriesPath, null)
131
+ if (!isRecord(index) || !isRecord(entries)) {
132
+ const hasEntries = isRecord(entries) && Object.keys(entries).length > 0
133
+ if (!hasEntries) {
134
+ // entries 损坏(已备份)或本就是空的:没有可保护的数据,按空旧库收尾。
135
+ writeJsonAtomic(formatPath, { version: 2, layout: 'sharded' })
136
+ for (const stale of [legacyEntriesPath, legacyIndexPath]) {
137
+ try {
138
+ unlinkSync(stale)
139
+ } catch {
140
+ // already renamed away by corrupt backup, or gone; nothing to do
141
+ }
142
+ }
143
+ return
144
+ }
145
+ // index.json 是 entries.json → rel 的唯一映射。它缺失或损坏时继续迁移,会写出 0 个
146
+ // shard、打上 v2 标记、再把**完好的** entries.json 删掉——等于一次静默的数据清空。
147
+ // 保留现场,不写 format 标记,等 index.json 修好后再迁(每次进程只提示一次)。
148
+ if (!migrationWarned.has(this.dir)) {
149
+ migrationWarned.add(this.dir)
150
+ console.error(`[dsh-project-memory] legacy store at ${this.dir} has ${ENTRIES_FILE} but no readable ${INDEX_FILE}; migration skipped to protect it`)
151
+ }
152
+ return
153
+ }
154
+ const files = isRecord(index.files) ? index.files : {}
120
155
  const orphans = Object.keys(entries).filter((rel) => !(rel in files))
121
156
  if (orphans.length) {
122
157
  console.error(
@@ -147,9 +182,11 @@ export class ProjectMemoryStore {
147
182
  }
148
183
  for (const name of shardNames) {
149
184
  const shard = loadJson(path.join(this.dir, SHARDS_DIR, name), null)
150
- if (!shard || typeof shard.relPath !== 'string' || !shard.record) continue
185
+ if (!shard || typeof shard.relPath !== 'string' || !isRecord(shard.record)) continue
151
186
  this.files[shard.relPath] = shard.record
152
- this.entries[shard.relPath] = shard.entries || []
187
+ // 畸形 shard(entries 被写成对象/null)不能让 allEntries() 在 `for…of` 上抛错,
188
+ // 否则一个坏文件会拖垮整个进程的每一次读取。
189
+ this.entries[shard.relPath] = Array.isArray(shard.entries) ? shard.entries.filter(isRecord) : []
153
190
  }
154
191
  this.experience = loadJson(path.join(this.dir, EXPERIENCE_FILE), [])
155
192
  this.tasks = loadJson(path.join(this.dir, TASKS_FILE), [])
@@ -165,7 +202,10 @@ export class ProjectMemoryStore {
165
202
  // migratedAt 落盘保证跨进程/崩溃幂等。销毁式收敛放到 recall 统一 PR。
166
203
  _loadInsights() {
167
204
  const doc = loadJson(path.join(this.dir, INSIGHTS_FILE), null)
168
- this.insights = doc && typeof doc === 'object' && Array.isArray(doc.items) ? doc : { version: 1, migratedAt: null, items: [] }
205
+ this.insights = isRecord(doc) && Array.isArray(doc.items)
206
+ // items 里混进 null/非对象(手改或旧版写入)会让迁移与召回逐个 `.title` 抛错——过滤掉。
207
+ ? { ...doc, items: doc.items.filter(isRecord) }
208
+ : { version: 1, migratedAt: null, items: [] }
169
209
  this._migrateExperienceToInsights()
170
210
  // PR3:v1 → v2 懒回填派生 trigger(纯确定性、幂等;不调用模型,不改写已有字段)
171
211
  if (backfillDerivedTriggers(this.insights)) this._dirtyInsights = true
@@ -551,15 +591,14 @@ export class ProjectMemoryStore {
551
591
  return result
552
592
  }
553
593
 
554
- applyFileUpdate(relPath, { expectedHash, hash, entries, meta, deleted, type, size }) {
555
- const cur = this.fileRecord(relPath)
556
- if (!deleted && (cur?.sha256 ?? null) !== (expectedHash ?? null)) {
557
- return { skipped: true }
558
- }
559
- if (deleted) {
560
- this.removeFile(relPath)
561
- return { ok: true }
562
- }
594
+ /**
595
+ * 写入一条文件更新,以 expectedHash 做 CAS。
596
+ * @returns {boolean} true = 已写入;false = 文件自扫描后又被改动,本次拒绝
597
+ * (调用方让该条目保持未落快照,下一轮重试)。
598
+ * 移除条目不经过这里:调用方在 commit 里直接 removeFile(见 index-pipeline.js)。
599
+ */
600
+ applyFileUpdate(relPath, { expectedHash, hash, entries, type, size }) {
601
+ if ((this.fileRecord(relPath)?.sha256 ?? null) !== (expectedHash ?? null)) return false
563
602
  this.markFile(relPath, {
564
603
  sha256: hash,
565
604
  size,
@@ -567,7 +606,7 @@ export class ProjectMemoryStore {
567
606
  indexedAt: new Date().toISOString(),
568
607
  })
569
608
  this.setEntries(relPath, entries)
570
- return { ok: true }
609
+ return true
571
610
  }
572
611
  }
573
612
 
package/src/symbols.js CHANGED
@@ -144,18 +144,57 @@ function extractTypeSignature(line) {
144
144
  return sig
145
145
  }
146
146
 
147
- function extractInterfaceOrType(line) {
148
- // interface User { name: string; age: number }
149
- const ifaceMatch = line.match(/^interface\s+(\w+)\s*(?:extends\s+[^{]+)?\s*\{([^}]*)\}/)
150
- if (ifaceMatch) {
151
- return `{ ${ifaceMatch[2].trim()} }`
147
+ function braceDelta(s) {
148
+ let d = 0
149
+ for (const ch of s) {
150
+ if (ch === '{') d++
151
+ else if (ch === '}') d--
152
152
  }
153
- // type UserMap = Map<string, User>
154
- const typeMatch = line.match(/^type\s+(\w+)\s*=\s*([^;{]+)/)
155
- if (typeMatch) {
156
- return `= ${typeMatch[2].trim()}`
153
+ return d
154
+ }
155
+
156
+ /**
157
+ * 读取一条 interface / type 声明,支持 `export`(含 `declare`)与多行形态。
158
+ * 旧实现要求 `^interface`(不吃 export)且 `}` 必须在本行,于是
159
+ * `export interface X {…}`、任何多行 interface、`export type X = …` 在 L1 正则扫描器里
160
+ * 全都产出 0 个符号(TS 增强器可用时才被补回来;没有 typescript 的项目就彻底看不到)。
161
+ * @returns {{kind: 'interface'|'type', name: string, text: string, endIdx: number} | null}
162
+ */
163
+ function readTypeDeclaration(rawLines, startIdx) {
164
+ const first = rawLines[startIdx].trim()
165
+ const m = first.match(/^(?:export\s+)?(?:declare\s+)?(interface|type)\s+([A-Za-z_$][\w$]*)/)
166
+ if (!m) return null
167
+ const kind = m[1]
168
+ const parts = [first]
169
+ let depth = braceDelta(first)
170
+ let endIdx = startIdx
171
+ const complete = () => (kind === 'interface'
172
+ ? depth <= 0 && parts[parts.length - 1].includes('}')
173
+ : depth <= 0 && /[;}]/.test(parts[parts.length - 1]))
174
+ for (let j = startIdx + 1; j < rawLines.length && j - startIdx <= 20 && !complete(); j++) {
175
+ const t = rawLines[j].trim()
176
+ if (!t) {
177
+ if (depth <= 0) break
178
+ parts.push(t)
179
+ endIdx = j
180
+ continue
181
+ }
182
+ // 归零后遇到下一条声明开头就收尾(TS 的 type 别名常不写分号)
183
+ if (depth <= 0 && j > startIdx && /^(?:export\s+)?(?:declare\s+)?(?:interface|type|function|class|const|let|var|import|enum)\b/.test(t)) break
184
+ parts.push(t)
185
+ depth += braceDelta(t)
186
+ endIdx = j
157
187
  }
158
- return ''
188
+ return { kind, name: m[2], text: parts.join(' '), endIdx }
189
+ }
190
+
191
+ function formatTypeDeclaration({ kind, text }) {
192
+ if (kind === 'interface') {
193
+ const body = text.match(/\{([\s\S]*)\}/)
194
+ return body ? `{ ${body[1].replace(/\s+/g, ' ').trim()} }` : ''
195
+ }
196
+ const eq = text.match(/=\s*([\s\S]+?);?\s*$/)
197
+ return eq ? `= ${eq[1].replace(/\s+/g, ' ').trim()}` : ''
159
198
  }
160
199
 
161
200
  function extractOverloads(masked, startIdx) {
@@ -226,6 +265,7 @@ function scanJsLike(masked, relPath, rawLines) {
226
265
  const symbols = []
227
266
  let prevOpensBlock = false
228
267
  for (let i = 0; i < masked.length; i++) {
268
+ const declStart = i // 多行声明会推进 i,符号行号要记声明的**首行**
229
269
  const rawText = rawLines[i].trim()
230
270
  const maskedText = masked[i].trim()
231
271
  if (!rawText) continue
@@ -233,13 +273,12 @@ function scanJsLike(masked, relPath, rawLines) {
233
273
  let matched = null
234
274
  let joinedText = null
235
275
 
236
- // Check for interface / type alias first (on raw line, not masked)
237
- const ifaceSig = extractInterfaceOrType(rawText)
238
- if (ifaceSig) {
239
- const nameMatch = rawText.match(/^(?:export\s+)?(?:interface|type)\s+(\w+)/)
240
- if (nameMatch) {
241
- matched = { name: nameMatch[1], kind: 'interface', typeSig: ifaceSig }
242
- }
276
+ // interface / type(支持 export 与多行;没有 TS 增强器时这是唯一来源)
277
+ const typeDecl = readTypeDeclaration(rawLines, i)
278
+ if (typeDecl) {
279
+ const sig = formatTypeDeclaration(typeDecl)
280
+ if (sig) matched = { name: typeDecl.name, kind: typeDecl.kind, typeSig: sig }
281
+ i = typeDecl.endIdx
243
282
  }
244
283
 
245
284
  if (!matched) {
@@ -294,7 +333,7 @@ function scanJsLike(masked, relPath, rawLines) {
294
333
  if (overloads.length > 1) matched.overloads = overloads
295
334
  }
296
335
 
297
- symbols.push(buildSymbol(matched, relPath, rawLines[i], i + 1))
336
+ symbols.push(buildSymbol(matched, relPath, rawLines[declStart], declStart + 1))
298
337
  }
299
338
 
300
339
  prevOpensBlock = masked[i].trim().endsWith('{')
@@ -433,7 +472,7 @@ function buildSymbol(matched, relPath, rawLine, lineNo) {
433
472
  export function scanSymbols(a, b, c) {
434
473
  // Backward compatible: old signature (filePath, content) or new (relPath, filePath, content)
435
474
  const [relPath, filePath, content] = c === undefined ? [a, a, b] : [a, b, c]
436
- const ext = relPath.slice(relPath.lastIndexOf('.'))
475
+ const ext = relPath.slice(relPath.lastIndexOf('.')).toLowerCase()
437
476
  const lines = content.split(/\r?\n/)
438
477
  if (JS_LIKE.has(ext)) return scanJsLike(maskTokens(lines, JS_MASKER), relPath, lines)
439
478
  if (PYTHON.has(ext)) return scanPython(maskTokens(lines, PY_MASKER), relPath, lines)
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
2
+ import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
3
  import { ProjectMemoryStore } from '../store.js'
4
4
 
5
5
  export function forgetTool(config) {
@@ -24,6 +24,7 @@ export function forgetTool(config) {
24
24
  },
25
25
  async execute(args, exec) {
26
26
  const root = resolveIndexRoot(exec, args.root)
27
+ assertIndexRoot(root, args.root || root)
27
28
  const memoryDir = memoryRootFor(root, config.memoryDir)
28
29
  const store = new ProjectMemoryStore(memoryDir).load()
29
30
  return store.commit((s) => {
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import path from 'node:path'
3
3
  import { assertReadableFile, memoryRootFor, sha256OfFile, storeKey } from '../util/fs.js'
4
4
  import { buildDocEntries } from '../doc-pipeline.js'
5
+ import { docEntriesNeedBackfill } from '../doc-index.js'
5
6
  import { linkEntries } from '../link.js'
6
7
  import { ProjectMemoryStore } from '../store.js'
7
8
  import { findProjectRoot } from '../lazy.js'
@@ -38,7 +39,9 @@ export function indexDocTool(ctx, config) {
38
39
  const rel = storeKey(path.relative(root, filePath).split(path.sep).join('/'))
39
40
  const { hash, size } = await sha256OfFile(filePath)
40
41
  const existing = store.fileRecord(rel)
41
- if (existing && existing.sha256 === hash) {
42
+ // 与 index_repo/watch/lazy 同一条判据:哈希未变但旧条目缺 terms 时仍要重抽一次(一次性回填)。
43
+ // 少了这一步,terms 回填就是"路径相关"的——只有走 index_repo 才生效。
44
+ if (existing && existing.sha256 === hash && !docEntriesNeedBackfill(store.entries[rel])) {
42
45
  return `Skipped (unchanged): ${rel}\nAlready indexed with ${(store.entries[rel] || []).length} entry/entries.`
43
46
  }
44
47
 
@@ -1,11 +1,8 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import path from 'node:path'
3
3
  import { statSync } from 'node:fs'
4
- import { assertIndexRoot, isSupportedCode, isSupportedDoc, looksLikeDump, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from '../util/fs.js'
5
- import { buildDocEntries } from '../doc-pipeline.js'
6
- import { docEntriesNeedBackfill } from '../doc-index.js'
7
- import { scanSymbols } from '../symbols.js'
8
- import { linkEntries } from '../link.js'
4
+ import { assertIndexRoot, memoryRootFor, relativePath, storeKey, walkDir } from '../util/fs.js'
5
+ import { DROP, OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from '../index-pipeline.js'
9
6
  import { ProjectMemoryStore } from '../store.js'
10
7
  import { onFileIndexed, isTypeScriptFile } from '../enhancer.js'
11
8
 
@@ -16,109 +13,59 @@ export async function indexRepository(ctx, config, root, { reindex = false } = {
16
13
  const memoryDir = memoryRootFor(root, config.memoryDir)
17
14
  const store = new ProjectMemoryStore(memoryDir).load()
18
15
 
19
- const files = walkDir(root)
20
16
  const seen = new Set()
17
+ const updates = []
18
+ const failures = []
21
19
  let indexed = 0
22
20
  let updated = 0
23
21
  let skipped = 0
24
- let removed = 0
25
- const failures = []
26
-
27
- // First pass: collect all file info and compute hashes/entries (async work outside commit)
28
- const fileUpdates = []
29
22
 
30
- for (const filePath of files) {
23
+ for (const filePath of walkDir(root)) {
31
24
  const rel = storeKey(relativePath(root, filePath))
32
25
  seen.add(rel)
33
- const ext = path.extname(filePath).toLowerCase()
34
- if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
26
+ const kind = fileKind(path.extname(filePath).toLowerCase())
27
+ if (!kind) continue
35
28
 
36
29
  try {
37
30
  const size = statSync(filePath).size
38
- const existing = store.fileRecord(rel)
39
- if (isSupportedCode(ext) && config.maxFileSizeMb && size > config.maxFileSizeMb * 1024 * 1024) {
40
- fileUpdates.push({ rel, deleted: true })
31
+ const record = store.fileRecord(rel)
32
+ const plan = await planFileIndex({ rel, filePath, kind, config, record, existingEntries: store.entries[rel], size, force: reindex })
33
+ if (plan.key === UNCHANGED) {
41
34
  skipped++
42
35
  continue
43
36
  }
44
-
45
- // 单次读盘:同一 buffer 供哈希与正文使用(不再 sha256OfFile + readFileSync 读两遍)
46
- const { hash, buffer } = readFileForIndex(filePath)
47
- // 旧 store 的 doc 条目缺 terms → 即使哈希未变也重抽一次(一次性回填)
48
- const needsBackfill = isSupportedDoc(ext) && docEntriesNeedBackfill(store.entries[rel])
49
- if (!reindex && existing && existing.sha256 === hash && !needsBackfill) {
37
+ if (plan.key === OVERSIZE) {
38
+ // 体积超限的代码文件:旧记录一律清掉,避免检索命中一个已经不索引的文件。
39
+ updates.push({ rel, drop: true })
50
40
  skipped++
51
41
  continue
52
42
  }
53
-
54
- let entries
55
- if (isSupportedCode(ext)) {
56
- entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
57
- fileUpdates.push({ rel, expectedHash: existing?.sha256, hash, size, entries, type: 'code' })
58
- updated++
59
- } else {
60
- const content = buffer.toString('utf8')
61
- if (looksLikeDump(content)) {
62
- fileUpdates.push({ rel, deleted: true })
63
- skipped++
64
- continue
65
- }
66
- entries = await buildDocEntries(rel, filePath, {
67
- chunkChars: config.chunkChars,
68
- maxChunks: config.maxChunksPerFile,
69
- maxFileSizeMb: config.maxFileSizeMb,
70
- maxPdfPages: config.maxPdfPages,
71
- })
72
- if (entries === null) {
73
- fileUpdates.push({ rel, deleted: true })
74
- skipped++
75
- continue
76
- }
77
- fileUpdates.push({ rel, expectedHash: existing?.sha256, hash, size, entries, type: 'doc' })
78
- indexed++
79
- }
80
- } catch (err) {
43
+ updates.push(toFileUpdate(rel, plan, record))
44
+ if (plan.key === DROP) skipped++
45
+ else if (plan.type === 'code') updated++
46
+ else indexed++
47
+ } catch {
81
48
  failures.push(rel)
82
49
  }
83
50
  }
84
51
 
85
- // Second pass: single commit with all updates
86
- const report = store.commit((s) => {
87
- for (const update of fileUpdates) {
88
- const result = s.applyFileUpdate(update.rel, update)
89
- if (result.skipped) {
90
- // CAS failed - file was modified concurrently, skip
91
- continue
92
- }
93
- }
94
-
95
- // Remove files not seen
96
- for (const rel of Object.keys(s.files)) {
97
- if (!seen.has(rel)) {
98
- s.removeFile(rel)
99
- removed++
100
- }
101
- }
102
-
103
- linkEntries(s)
104
- const stats = s.stats()
105
- let report =
106
- `Indexed project: ${root}\n` +
107
- `docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
108
- `memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
109
- if (failures.length) {
110
- report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
111
- }
112
- return report
113
- })
52
+ // 单事务提交:写入 + 清理本轮未见到的旧条目 + 重建链接。
53
+ const { removed } = commitFileUpdates(store, { updates, unseen: seen })
54
+ const stats = store.stats()
55
+ let report =
56
+ `Indexed project: ${root}\n` +
57
+ `docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
58
+ `memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
59
+ if (failures.length) {
60
+ report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
61
+ }
114
62
 
115
63
  // Trigger TS enhancement for code files (TS/JS only)
116
- for (const update of fileUpdates) {
117
- if (update.type === 'code') {
118
- const filePath = path.join(root, update.rel)
119
- if (isTypeScriptFile(filePath)) {
120
- onFileIndexed(store, update.rel, filePath, config, root)
121
- }
64
+ for (const update of updates) {
65
+ if (update.type !== 'code') continue
66
+ const filePath = path.join(root, update.rel)
67
+ if (isTypeScriptFile(filePath)) {
68
+ onFileIndexed(store, update.rel, filePath, config, root)
122
69
  }
123
70
  }
124
71
 
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
2
+ import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
3
  import { ProjectMemoryStore } from '../store.js'
4
4
  import { truncate } from '../util/text.js'
5
5
  import { cfgInsight, saveInsight, defaultGlobalFile, GlobalStore } from '../insight-store.js'
@@ -77,6 +77,7 @@ export function lessonTool(config) {
77
77
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
78
78
  async execute(args, exec) {
79
79
  const root = resolveIndexRoot(exec, args.root)
80
+ assertIndexRoot(root, args.root || root)
80
81
  const memoryDir = memoryRootFor(root, config.memoryDir)
81
82
  const store = new ProjectMemoryStore(memoryDir).load()
82
83
  const cfg = cfgInsight(config)
@@ -4,13 +4,22 @@ import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
4
4
  import { ProjectMemoryStore, storeOverview } from '../store.js'
5
5
  import { expandQuery } from '../llm.js'
6
6
  import { resolveRoute } from '../llm-route.js'
7
- import { GlobalStore, cfgInsight, defaultGlobalFile } from '../insight-store.js'
7
+ import { GlobalStore, cfgInsight, defaultGlobalFile, recordHit } from '../insight-store.js'
8
8
  import { recallItems } from '../recall.js'
9
9
  import { truncate } from '../util/text.js'
10
10
  function toAbs(root, rel) {
11
11
  return path.isAbsolute(rel) ? rel : path.join(root, rel)
12
12
  }
13
13
 
14
+ /** 步骤在历史数据里可能是字符串或 {content|text, status}:TaskBridge/注入/反思都按 content||text 读。 */
15
+ function stepContent(s) {
16
+ if (typeof s === 'string') return s
17
+ return s?.content ?? s?.text ?? ''
18
+ }
19
+ function stepStatus(s) {
20
+ return typeof s === 'string' ? 'pending' : (s?.status || 'pending')
21
+ }
22
+
14
23
  export function queryMemoryTool(ctx, config) {
15
24
  return defineTool({
16
25
  name: 'query_memory',
@@ -46,6 +55,11 @@ export function queryMemoryTool(ctx, config) {
46
55
  },
47
56
  async execute(args, exec) {
48
57
  const root = resolveIndexRoot(exec, args.root)
58
+ // 空查询不是"全部":recallItems 会过滤空串,rankEntriesStreaming 随即返回
59
+ // entries.slice(0, limit)(任意条目、分数 0),task 分支的 includes('') 更是命中所有任务。
60
+ if (!String(args.query ?? '').trim()) {
61
+ return 'query must not be empty. Use memory_stats to see what the store contains.'
62
+ }
49
63
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
50
64
  const type = args.type || 'all'
51
65
  const limit = Math.max(1, Math.min(Number(args.limit) || 8, 20))
@@ -79,6 +93,17 @@ export function queryMemoryTool(ctx, config) {
79
93
  })
80
94
 
81
95
  const lines = []
96
+ // 使用记账:检索命中也是"被用到"(活跃度/衰减用得上它,见 recordHit)。查询是热路径
97
+ // (p50 2.6ms),这里只改内存 + 标脏,**不**强制落盘——注入路径会立即落盘,这条路
98
+ // 靠下一次自然 save 带上。记账失败绝不影响查询结果。
99
+ if (wantInsight) {
100
+ try {
101
+ const bucket = recalled.layers.find((l) => l.layer === 'insight')
102
+ recordHit({ store, globalStore, ids: (bucket?.hits || []).map((h) => h.item?.insightId) })
103
+ } catch {
104
+ /* ignore:记账是旁路 */
105
+ }
106
+ }
82
107
  if (wantMemory) {
83
108
  // doc/symbol 同源同尺度:合并后按加权分排序(规范段提权已计入 weightedScore)
84
109
  const memHits = recalled.layers
@@ -146,16 +171,16 @@ export function queryMemoryTool(ctx, config) {
146
171
  if (type === 'task') {
147
172
  const q = (queries[0] || '').toLowerCase()
148
173
  const matched = tasks
149
- .filter((t) => !t.archived && (t.title.toLowerCase().includes(q) || (t.steps || []).some((s) => s.text?.toLowerCase().includes(q)) || (t.files || []).some((f) => f.toLowerCase().includes(q))))
174
+ .filter((t) => !t.archived && (t.title.toLowerCase().includes(q) || (t.steps || []).some((s) => stepContent(s).toLowerCase().includes(q)) || (t.files || []).some((f) => f.toLowerCase().includes(q))))
150
175
  .slice(0, limit)
151
176
  if (!matched.length) {
152
177
  return `任务记录: 0 套匹配 "${args.query}"(list_tasks 查看全部,select_task 续做)`
153
178
  }
154
179
  for (const t of matched) {
155
- const done = (t.steps || []).filter((s) => s.status === 'completed').length
180
+ const done = (t.steps || []).filter((s) => stepStatus(s) === 'completed').length
156
181
  const total = (t.steps || []).length
157
182
  const stepsText = (t.steps || []).length
158
- ? (t.steps || []).map((s) => `- [${s.status === 'completed' ? 'x' : s.status === 'in_progress' ? '*' : ' '}] ${s.text}`).join('\n')
183
+ ? (t.steps || []).map((s) => `- [${stepStatus(s) === 'completed' ? 'x' : stepStatus(s) === 'in_progress' ? '*' : ' '}] ${stepContent(s)}`).join('\n')
159
184
  : '(无步骤)'
160
185
  const files = (t.files || []).slice(0, 8).join(', ')
161
186
  lines.push(`### ${t.title} (${done}/${total} 完成)\n${stepsText}\n- 文件: ${files || '无'}`)
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
2
+ import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
3
  import { ProjectMemoryStore } from '../store.js'
4
4
 
5
5
  export function rememberTool(config) {
@@ -35,6 +35,8 @@ export function rememberTool(config) {
35
35
  },
36
36
  async execute(args, exec) {
37
37
  const root = resolveIndexRoot(exec, args.root)
38
+ // 写盘前校验根:否则一个拼错的 root 会被 mkdir 成字面量目录(连同 .dsh-project-memory)。
39
+ assertIndexRoot(root, args.root || root)
38
40
  const memoryDir = memoryRootFor(root, config.memoryDir)
39
41
  const store = new ProjectMemoryStore(memoryDir).load()
40
42
  return store.commit((s) => {