@yolk_vat-y/dsh-project-memory 0.5.3 → 0.5.5

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.
@@ -11,7 +11,7 @@ function sessionIdOf(exec) {
11
11
  }
12
12
 
13
13
  export function lessonTool(config) {
14
- const kindDesc = `insight 语义:lesson(曾踩坑/纠偏, pattern→fix) | decision(权衡选型, choice→reason) | procedure(多步指南, steps+可选 trigger) | experience(problem→solution,v0.4 兼容)。默认 lesson。`
14
+ const kindDesc = `insight 语义:lesson(曾踩坑/纠偏, pattern→fix) | decision(权衡选型, choice→reason) | procedure(多步指南, steps) | experience(problem→solution,v0.4 兼容)。默认 lesson。所有 kind 都可带 trigger:命中即在动手前确定性注入。`
15
15
  const scopeDesc =
16
16
  '作用域:task(任务私有,随任务归档,不进共享注入) | project(项目资产) | global(个人能力库,跨项目,建议配 trigger 关键词以便将来技能命中)。' +
17
17
  '解析顺序:显式 scope > task_id > 当前绑定任务 > project。'
@@ -37,9 +37,14 @@ export function lessonTool(config) {
37
37
  properties: {
38
38
  keywords: { type: 'array', items: { type: 'string' } },
39
39
  symbols: { type: 'array', items: { type: 'string' } },
40
+ actions: { type: 'array', items: { type: 'string' } },
41
+ paths: { type: 'array', items: { type: 'string' } },
40
42
  scope: { type: 'array', items: { type: 'string' } },
41
43
  },
42
- description: 'kind=procedure: "作为 Skill" 的触发关键词(keywords/symbols + scope tags)',
44
+ description:
45
+ 'authored trigger(所有 kind 通用):命中即在**动手前**确定性注入本条。' +
46
+ 'keywords/symbols 匹配人类消息与工具参数;actions 用归一 id(git-commit / npm-publish / go-public / deploy / delete / migrate …);' +
47
+ 'paths 用 glob(README* / CHANGELOG* / .gitignore);scope 按项目画像 tags 过滤。留空则只可能作为统计提示注入。',
43
48
  },
44
49
  task_id: { type: 'string', description: '目标任务 id(scope 缺省时优先于绑定任务)' },
45
50
  files: { type: 'array', items: { type: 'string' }, description: '关联文件(项目相对路径)' },
@@ -3,9 +3,10 @@ import path from 'node:path'
3
3
  import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
4
4
  import { ProjectMemoryStore, storeOverview } from '../store.js'
5
5
  import { expandQuery } from '../llm.js'
6
- import { rankEntriesMergedScored, rankExperienceScored, rankEntriesStreaming } from '../util/search.js'
6
+ import { resolveRoute } from '../llm-route.js'
7
+ import { GlobalStore, cfgInsight, defaultGlobalFile } from '../insight-store.js'
8
+ import { recallItems } from '../recall.js'
7
9
  import { truncate } from '../util/text.js'
8
-
9
10
  function toAbs(root, rel) {
10
11
  return path.isAbsolute(rel) ? rel : path.join(root, rel)
11
12
  }
@@ -14,9 +15,10 @@ export function queryMemoryTool(ctx, config) {
14
15
  return defineTool({
15
16
  name: 'query_memory',
16
17
  description:
17
- 'Search persistent project memory: doc summaries and code symbol tables (indexed via index_doc/index_repo) ' +
18
- 'plus experience notes (problem -> solution, saved via remember). Every hit returns its source path and line so ' +
19
- 'you can verify by reading the real file. Docs are cross-linked to the code symbols they mention. ' +
18
+ 'Search persistent project memory: doc summaries and code symbol tables (indexed via index_doc/index_repo), ' +
19
+ 'experience notes (problem -> solution, saved via remember) and insights (lessons / decisions / procedures, ' +
20
+ 'saved via save_lesson). Every hit returns its source path and line, or its insight id, so you can verify. ' +
21
+ 'Docs are cross-linked to the code symbols they mention. ' +
20
22
  'Use BEFORE grepping when you need orientation, a spec constraint, or a past decision.',
21
23
  parameters: {
22
24
  query: {
@@ -30,8 +32,8 @@ export function queryMemoryTool(ctx, config) {
30
32
  },
31
33
  type: {
32
34
  type: 'string',
33
- enum: ['all', 'doc', 'symbol', 'experience', 'task'],
34
- description: 'Which memory layer to search. Default "all". "task" searches task records (title/steps/files).',
35
+ enum: ['all', 'doc', 'symbol', 'experience', 'insight', 'task'],
36
+ description: 'Which memory layer to search. Default "all". "insight" searches lessons / decisions / procedures; "task" searches task records.',
35
37
  },
36
38
  limit: {
37
39
  type: 'number',
@@ -49,25 +51,47 @@ export function queryMemoryTool(ctx, config) {
49
51
  const limit = Math.max(1, Math.min(Number(args.limit) || 8, 20))
50
52
 
51
53
  const queries = config.llmQueryExpansion
52
- ? await expandQuery(ctx.llm, args.query, config.expansionCount)
54
+ ? await expandQuery(ctx.llm, args.query, config.expansionCount, { route: resolveRoute(exec, config) })
53
55
  : [args.query]
54
56
  const symbolById = new Map()
55
57
  for (const e of store.allEntries()) {
56
58
  if (e.type === 'symbol') symbolById.set(e.id, e)
57
59
  }
58
60
 
59
- const idf = store.getIdfCache()
61
+ // 会话绑定决定 task 级 insight 的可见性;global 级始终可见。
62
+ const sessionId = exec?.agent?.session?.id
63
+ const boundTaskId = sessionId && typeof store.getBoundTaskId === 'function' ? store.getBoundTaskId(sessionId) : null
64
+ const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
65
+ const wantMemory = type === 'all' || type === 'doc' || type === 'symbol'
66
+ const wantInsight = type === 'all' || type === 'insight'
67
+ // 一次 recall 覆盖全部层:doc/symbol/experience/insight 共用同一个检索核心(src/recall.js)。
68
+ const recalled = recallItems({
69
+ store,
70
+ globalStore,
71
+ queries,
72
+ boundTaskId,
73
+ limit,
74
+ layers: [
75
+ ...(wantMemory ? (type === 'symbol' ? ['symbol'] : type === 'doc' ? ['doc'] : ['doc', 'symbol']) : []),
76
+ ...(wantInsight ? ['insight'] : []),
77
+ ...(type === 'all' || type === 'experience' ? ['experience'] : []),
78
+ ],
79
+ })
60
80
 
61
81
  const lines = []
62
- if (type === 'all' || type === 'doc' || type === 'symbol') {
63
- const pool = type === 'all' ? store.allEntries() : store.allEntries().filter((e) => e.type === type)
64
- const scored = rankEntriesStreaming(pool, queries, idf, limit)
65
- if (scored.length) {
66
- const top = scored[0].score || 1
82
+ if (wantMemory) {
83
+ // doc/symbol 同源同尺度:合并后按加权分排序(规范段提权已计入 weightedScore)
84
+ const memHits = recalled.layers
85
+ .filter((l) => l.layer === 'doc' || l.layer === 'symbol')
86
+ .flatMap((l) => l.hits)
87
+ .sort((a, b) => b.weightedScore - a.weightedScore)
88
+ .slice(0, limit)
89
+ if (memHits.length) {
90
+ const top = memHits[0].weightedScore || 1
67
91
  lines.push(`## Memory (${type === 'all' ? 'docs + symbols' : type})`)
68
- for (const { entry: e, score } of scored) {
92
+ for (const { item: e, weightedScore } of memHits) {
69
93
  const absSource = e.sourceLine ? `${toAbs(root, e.sourcePath)}:${e.sourceLine}` : toAbs(root, e.sourcePath)
70
- const rel = Math.round((score / top) * 100)
94
+ const rel = Math.round((weightedScore / top) * 100)
71
95
  let summaryLine = `- ${e.summary}`
72
96
  if (e.type === 'doc' && e.blindSpots) {
73
97
  const queryTokens = queries.flatMap(q => q.split(/[\s\-_]+/)).map(t => t.toLowerCase()).filter(Boolean)
@@ -77,7 +101,9 @@ export function queryMemoryTool(ctx, config) {
77
101
  summaryLine += `\n- ⚠️ 摘要未覆盖:${e.blindSpots.replace(/^\s*\/\/\s*未覆盖[::]\s*/, '')}。建议读原文 ${absSource}`
78
102
  }
79
103
  }
80
- lines.push(`### ${e.title} (score: ${rel})\n- source: ${absSource}\n${summaryLine}`)
104
+ // 条目状态占位(可证伪状态机落地前恒为 exact)。
105
+ // 先立字段,后续状态机到位时只改值、不改输出契约。
106
+ lines.push(`### ${e.title} (score: ${rel})\n- source: ${absSource}\n- status: ${e.status || 'exact'}\n${summaryLine}`)
81
107
  if (e.type === 'doc' && Array.isArray(e.linkedSymbols) && e.linkedSymbols.length) {
82
108
  const refs = e.linkedSymbols.slice(0, 5).map((id) => {
83
109
  const s = symbolById.get(id)
@@ -88,15 +114,28 @@ export function queryMemoryTool(ctx, config) {
88
114
  }
89
115
  }
90
116
  }
117
+ if (wantInsight) {
118
+ const bucket = recalled.layers.find((l) => l.layer === 'insight')
119
+ if (bucket && bucket.hits.length) {
120
+ lines.push('## Insights (lessons / decisions / procedures)')
121
+ for (const { item: e, rel } of bucket.hits) {
122
+ const scope = e.scope ? `, scope: ${e.scope}` : ''
123
+ const files = e.files && e.files.length ? `\n- files: ${e.files.map((f) => toAbs(root, f)).join(', ')}` : ''
124
+ const conf = typeof e.confidence === 'number' ? `\n- confidence: ${e.confidence}` : ''
125
+ lines.push(
126
+ `### ${e.title} (score: ${Math.round(rel * 100)}, kind: ${e.kind}${scope}, id: ${e.insightId})\n- ${e.summary}${files}${conf}`,
127
+ )
128
+ }
129
+ }
130
+ }
91
131
  if (type === 'all' || type === 'experience') {
92
- const scoredExp = rankExperienceScored(store.experience, queries, limit)
93
- if (scoredExp.length) {
94
- const expTop = scoredExp[0].score || 1
132
+ const expBucket = recalled.layers.find((l) => l.layer === 'experience')
133
+ if (expBucket && expBucket.hits.length) {
95
134
  lines.push(`## Experience (past problems -> solutions)`)
96
- for (const { item: e, score } of scoredExp) {
135
+ for (const { item: e, rel } of expBucket.hits) {
97
136
  const source = e.sourceFile ? ` (source: ${toAbs(root, e.sourceFile)})` : ''
98
137
  lines.push(
99
- `### Problem: ${e.problem} (score: ${Math.round((score / expTop) * 100)}, id: ${e.id})\n- solution: ${e.solution}${source}\n- updated: ${e.updatedAt}`,
138
+ `### Problem: ${e.problem} (score: ${Math.round(rel * 100)}, id: ${e.id})\n- solution: ${e.solution}${source}\n- updated: ${e.updatedAt}`,
100
139
  )
101
140
  }
102
141
  }
@@ -132,9 +171,11 @@ export function queryMemoryTool(ctx, config) {
132
171
  const hint =
133
172
  type === 'experience'
134
173
  ? 'Note a fix with remember so it can be recalled next time.'
135
- : type === 'all'
136
- ? 'Index it first with index_repo / index_doc, or note a fix with remember.'
137
- : 'Index it first with index_repo / index_doc.'
174
+ : type === 'insight'
175
+ ? 'Save a lesson / decision / procedure with save_lesson so it can be recalled next time.'
176
+ : type === 'all'
177
+ ? 'Index it first with index_repo / index_doc, or note a fix with remember or save_lesson.'
178
+ : 'Index it first with index_repo / index_doc.'
138
179
  const tail =
139
180
  overview.files === 0
140
181
  ? '. The store has never been indexed.'
@@ -4,6 +4,7 @@ import { ProjectMemoryStore } from '../store.js'
4
4
  import { truncate } from '../util/text.js'
5
5
  import { genTaskId, hash8, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
6
6
  import { fireReflect } from '../reflection-pipeline.js'
7
+ import { resolveRoute } from '../llm-route.js'
7
8
  import { createHash } from 'node:crypto'
8
9
 
9
10
  function sessionIdOf(exec) {
@@ -137,7 +138,7 @@ export function selectTaskTool(config, host) {
137
138
 
138
139
  // 反思钩子:切走旧任务时异步收割(默认关,零成本;失败只记日志)
139
140
  if (prevBound && prevBound !== task.id) {
140
- fireReflect(config, host, root, prevBound, 'switch-away')
141
+ fireReflect(config, host, root, prevBound, 'switch-away', resolveRoute(exec, config))
141
142
  }
142
143
 
143
144
  const card = {
@@ -191,7 +192,7 @@ export function archiveTaskTool(config, host) {
191
192
  task.updatedAt = new Date().toISOString()
192
193
  store.save()
193
194
  // 反思钩子:归档即收割最终教训(默认关)
194
- fireReflect(config, host, root, args.taskId, 'archive')
195
+ fireReflect(config, host, root, args.taskId, 'archive', resolveRoute(exec, config))
195
196
  return truncate(JSON.stringify({ success: true, archived: true, hint: '已归档,select_task 可恢复' }), config.maxOutputChars)
196
197
  },
197
198
  })
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import path from 'node:path'
3
- import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
+ import { existsSync } from 'node:fs'
4
+ import { isUnwatchableRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
4
5
  import { ProjectMemoryStore } from '../store.js'
5
6
 
6
7
  export function watchRepoTool(watchManager, config) {
@@ -27,6 +28,14 @@ export function watchRepoTool(watchManager, config) {
27
28
  },
28
29
  async execute(args, exec) {
29
30
  const root = path.resolve(args.root)
31
+ // 不存在的根不写进 watchlist:watch 每轮会 commit → save → mkdirSync,把它重新造出来
32
+ if (args.watch !== false && !existsSync(root)) {
33
+ return `Not watching: ${root} does not exist.`
34
+ }
35
+ // 文件系统根 / 共享临时目录不整体监听(子目录允许):会把无关程序和测试夹具的临时文件全扫进来
36
+ if (args.watch !== false && isUnwatchableRoot(root)) {
37
+ return `Refusing to watch ${root}: it is the filesystem root or the shared temp directory. Pass a project subdirectory instead.`
38
+ }
30
39
  const memoryDir = memoryRootFor(root, config.memoryDir)
31
40
  const sessionRoot = resolveIndexRoot(exec)
32
41
  const sessionMemoryDir = memoryRootFor(sessionRoot, config.memoryDir)
package/src/util/fs.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { createHash } from 'node:crypto'
4
+ import os from 'node:os'
4
5
  import path from 'node:path'
5
6
 
6
7
  export function assertReadableFile(filePath, maxFileSizeMb) {
@@ -23,6 +24,33 @@ export function assertReadableFile(filePath, maxFileSizeMb) {
23
24
  return filePath
24
25
  }
25
26
 
27
+ /** Windows-style absolute path (`D:\dir`, `C:/dir`) — not absolute on POSIX. */
28
+ const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/
29
+
30
+ /**
31
+ * 校验索引根目录:必须存在且是目录。
32
+ *
33
+ * 之前 `index_repo` / `autoIndexOnFirstUse` 只做 `path.resolve()`:在 Linux/macOS 上传入
34
+ * Windows 风格路径(如 `D:\project\foo`)会被解析成相对路径 `<cwd>/D:\project\foo`,
35
+ * 随后 store 的 `mkdirSync` 把这个字面量目录(连同其 `.dsh-project-memory`)真的建出来。
36
+ * 这里在写盘前拒绝不存在的根;`requested` 只用于在报错里点明“这看起来是 Windows 路径”。
37
+ */
38
+ export function assertIndexRoot(root, requested = root) {
39
+ let stats
40
+ try {
41
+ stats = statSync(root)
42
+ } catch {
43
+ const hint = process.platform !== 'win32' && WINDOWS_ABSOLUTE_PATH.test(String(requested))
44
+ ? ` The argument looks like a Windows path, which is not absolute on ${process.platform}.`
45
+ : ''
46
+ throw new Error(`Index root does not exist: ${root}.${hint}`)
47
+ }
48
+ if (!stats.isDirectory()) {
49
+ throw new Error(`Index root is not a directory: ${root}`)
50
+ }
51
+ return root
52
+ }
53
+
26
54
  export function sha256OfBuffer(buf) {
27
55
  return createHash('sha256').update(buf).digest('hex')
28
56
  }
@@ -117,6 +145,19 @@ export function memoryRootFor(indexRoot, memoryDir) {
117
145
  return path.join(indexRoot, memoryDir)
118
146
  }
119
147
 
148
+ /**
149
+ * 不该整体当「项目根」监听的目录:文件系统根,以及共享的 OS 临时目录。
150
+ * 监听后者会把无关程序(以及插件自己的测试夹具)的临时文件全扫进来,而且只要读过
151
+ * `/tmp` 下任意一个文件,findProjectRoot 的兜底就会把根解析成 `/tmp` 并自我固化。
152
+ * 根的**子目录**不受影响——项目放在临时目录的子目录里照常可监听。
153
+ */
154
+ export function isUnwatchableRoot(root) {
155
+ if (typeof root !== 'string' || !root) return true
156
+ const abs = path.resolve(root)
157
+ if (abs === path.parse(abs).root) return true
158
+ return abs === path.resolve(os.tmpdir())
159
+ }
160
+
120
161
  export function resolveIndexRoot(exec, explicitRoot) {
121
162
  if (explicitRoot && explicitRoot.trim()) return path.resolve(explicitRoot)
122
163
  const sessionCwd = exec?.agent?.session?.header?.cwd
@@ -131,6 +131,8 @@ export function weightedFieldText(entry) {
131
131
  for (let i = 0; i < 5; i++) parts.push(entry.title || '')
132
132
  parts.push((entry.keywords || []).join(' '))
133
133
  parts.push(entry.summary || '')
134
+ // 结构词项:覆盖整个 chunk 的字面词项,把「只有前 300 字符可检索」补回来(索引期零 LLM)
135
+ parts.push(entry.terms || '')
134
136
  parts.push(entry.sourcePath || '')
135
137
  return parts.join(' ')
136
138
  }
package/src/util/text.js CHANGED
@@ -2,4 +2,17 @@ export function truncate(text, maxChars) {
2
2
  if (typeof text !== 'string') return String(text)
3
3
  if (text.length <= maxChars) return text
4
4
  return text.slice(0, maxChars) + `\n\n...[truncated at ${maxChars} chars]`
5
+ }
6
+
7
+ export const MAX_SUMMARY = 300
8
+
9
+ /** 注入用短摘要:压平空白,按句子边界截断到 max 字符。纯函数,无 LLM。 */
10
+ export function summarizeText(text, max = MAX_SUMMARY) {
11
+ const flat = String(text || '').replace(/\s+/g, ' ').trim()
12
+ if (!flat) return ''
13
+ if (flat.length <= max) return flat
14
+ const clip = max - 1
15
+ const clipped = flat.slice(0, clip)
16
+ const lastBreak = Math.max(clipped.lastIndexOf('。'), clipped.lastIndexOf('.'), clipped.lastIndexOf(';'))
17
+ return lastBreak > clip * 0.4 ? clipped.slice(0, lastBreak + 1) : clipped + '…'
5
18
  }
package/src/watch.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { isSupportedCode, isSupportedDoc, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from './util/fs.js'
3
+ import { isSupportedCode, isSupportedDoc, isUnwatchableRoot, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from './util/fs.js'
4
4
  import { buildDocEntries } from './doc-pipeline.js'
5
+ import { docEntriesNeedBackfill } from './doc-index.js'
5
6
  import { scanSymbols } from './symbols.js'
6
7
  import { linkEntries } from './link.js'
7
8
  import { ProjectMemoryStore } from './store.js'
@@ -21,17 +22,34 @@ export class WatchManager {
21
22
  // 用 load() 的返回值:storeCache 命中时 load() 返回的是缓存实例,忽略返回值会拿到空 store
22
23
  const store = new ProjectMemoryStore(memoryRootFor(cwd, this.config.memoryDir)).load()
23
24
  if (existsSync(store.dir)) {
24
- for (const root of store.watchlist) {
25
- if (typeof root === 'string' && root) this.addRoot(root)
25
+ let dropped = 0
26
+ for (const root of [...store.watchlist]) {
27
+ if (typeof root !== 'string' || !root) continue
28
+ // 自愈剔除两类条目:已不存在的根(每轮白跑,还会把目录重新 mkdir 出来),
29
+ // 以及不该整体监听的文件系统根 / 共享临时目录(会把别人和测试的临时文件全扫进来)。
30
+ if (!existsSync(root) || isUnwatchableRoot(root)) {
31
+ store.removeWatch(root)
32
+ dropped++
33
+ continue
34
+ }
35
+ this.addRoot(root)
26
36
  }
37
+ if (dropped) store.save()
27
38
  }
28
39
  }
29
40
 
30
41
  addRoot(root) {
42
+ // 根目录不存在就拒绝:否则每轮 poll 都会 commit → save → mkdirSync,
43
+ // 把一条历史遗留、已被删除的 watchlist 条目重新「创建」出来。
44
+ if (!existsSync(root)) return false
45
+ // 文件系统根 / 共享临时目录不整体监听(子目录允许)。
46
+ if (isUnwatchableRoot(root)) return false
31
47
  if (!this.roots.has(root)) {
32
48
  this.roots.set(root, {
33
49
  store: new ProjectMemoryStore(memoryRootFor(root, this.config.memoryDir)).load(),
34
50
  snapshot: {},
51
+ // rel → 上次已上报的错误信息。坏文件每轮都会重试,逐轮打印会刷屏。
52
+ failures: new Map(),
35
53
  })
36
54
  return true
37
55
  }
@@ -105,14 +123,16 @@ export class WatchManager {
105
123
  // 单次读盘:同一 buffer 供哈希与正文使用
106
124
  const { hash, buffer } = readFileForIndex(filePath)
107
125
  const existing = state.store.fileRecord(rel)
108
- if (existing && existing.sha256 === hash) continue
126
+ // 旧 store 的 doc 条目缺 terms → 一次性回填(即使哈希未变)
127
+ const needsBackfill = isSupportedDoc(ext) && docEntriesNeedBackfill(state.store.entries[rel])
128
+ if (existing && existing.sha256 === hash && !needsBackfill) continue
109
129
 
110
130
  try {
111
131
  let entries
112
132
  if (isSupportedCode(ext)) {
113
133
  entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
114
134
  } else {
115
- entries = await buildDocEntries(this.ctx.llm, rel, filePath, {
135
+ entries = await buildDocEntries(rel, filePath, {
116
136
  chunkChars: this.config.chunkChars,
117
137
  maxChunks: this.config.maxChunksPerFile,
118
138
  maxFileSizeMb: this.config.maxFileSizeMb,
@@ -122,15 +142,22 @@ export class WatchManager {
122
142
  // Dump file - update snapshot so we don't re-hash next poll, but don't index
123
143
  fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, deleted: true, _sig: sig })
124
144
  changed++
145
+ state.failures?.delete(rel)
125
146
  continue
126
147
  }
127
148
  }
128
149
  fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, hash, size: stats.size, entries, type: isSupportedCode(ext) ? 'code' : 'doc', _sig: sig })
129
150
  changed++
151
+ state.failures?.delete(rel)
130
152
  } catch (err) {
131
153
  // Index failed - rollback snapshot so next poll retries
132
154
  delete state.snapshot[rel]
133
- console.error(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
155
+ // 同一个文件的同一个错误只上报一次:坏文件每轮都会重试,逐轮 console.error 会刷屏
156
+ if (!state.failures) state.failures = new Map()
157
+ if (state.failures.get(rel) !== err.message) {
158
+ state.failures.set(rel, err.message)
159
+ console.error(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
160
+ }
134
161
  continue
135
162
  }
136
163
  }