@yolk_vat-y/dsh-project-memory 0.5.6 → 0.5.7

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.
@@ -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)
@@ -11,6 +11,15 @@ 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))
@@ -146,16 +160,16 @@ export function queryMemoryTool(ctx, config) {
146
160
  if (type === 'task') {
147
161
  const q = (queries[0] || '').toLowerCase()
148
162
  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))))
163
+ .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
164
  .slice(0, limit)
151
165
  if (!matched.length) {
152
166
  return `任务记录: 0 套匹配 "${args.query}"(list_tasks 查看全部,select_task 续做)`
153
167
  }
154
168
  for (const t of matched) {
155
- const done = (t.steps || []).filter((s) => s.status === 'completed').length
169
+ const done = (t.steps || []).filter((s) => stepStatus(s) === 'completed').length
156
170
  const total = (t.steps || []).length
157
171
  const stepsText = (t.steps || []).length
158
- ? (t.steps || []).map((s) => `- [${s.status === 'completed' ? 'x' : s.status === 'in_progress' ? '*' : ' '}] ${s.text}`).join('\n')
172
+ ? (t.steps || []).map((s) => `- [${stepStatus(s) === 'completed' ? 'x' : stepStatus(s) === 'in_progress' ? '*' : ' '}] ${stepContent(s)}`).join('\n')
159
173
  : '(无步骤)'
160
174
  const files = (t.files || []).slice(0, 8).join(', ')
161
175
  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) => {
@@ -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 { genTaskId, hash8, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
@@ -40,6 +40,7 @@ export function listTasksTool(config) {
40
40
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
41
41
  async execute(args, exec) {
42
42
  const root = resolveIndexRoot(exec, args.root)
43
+ assertIndexRoot(root, args.root || root)
43
44
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
44
45
  const tasks = store.getTasks()
45
46
  if (!tasks.length) {
@@ -66,6 +67,7 @@ export function selectTaskTool(config, host) {
66
67
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
67
68
  async execute(args, exec) {
68
69
  const root = resolveIndexRoot(exec, args.root)
70
+ assertIndexRoot(root, args.root || root)
69
71
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
70
72
  const sid = sessionIdOf(exec)
71
73
  const now = new Date().toISOString()
@@ -184,6 +186,7 @@ export function archiveTaskTool(config, host) {
184
186
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
185
187
  async execute(args, exec) {
186
188
  const root = resolveIndexRoot(exec, args.root)
189
+ assertIndexRoot(root, args.root || root)
187
190
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
188
191
  const task = store.getTask(args.taskId)
189
192
  if (!task) return truncate(JSON.stringify({ success: false, error: 'Task not found' }), config.maxOutputChars)
@@ -1,7 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import path from 'node:path'
3
- import { existsSync } from 'node:fs'
4
- import { isUnwatchableRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
+ import { assertIndexRoot, isUnwatchableRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
5
4
  import { ProjectMemoryStore } from '../store.js'
6
5
 
7
6
  export function watchRepoTool(watchManager, config) {
@@ -28,9 +27,14 @@ export function watchRepoTool(watchManager, config) {
28
27
  },
29
28
  async execute(args, exec) {
30
29
  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.`
30
+ // 不存在的根不写进 watchlist:watch 每轮会 commit → save → mkdirSync,把它重新造出来。
31
+ // 根是**文件**时同样要拦:否则 save() 的 mkdirSync 会抛裸 ENOTDIR。
32
+ if (args.watch !== false) {
33
+ try {
34
+ assertIndexRoot(root, args.root)
35
+ } catch (err) {
36
+ return `Not watching: ${err.message}`
37
+ }
34
38
  }
35
39
  // 文件系统根 / 共享临时目录不整体监听(子目录允许):会把无关程序和测试夹具的临时文件全扫进来
36
40
  if (args.watch !== false && isUnwatchableRoot(root)) {
package/src/util/fs.js CHANGED
@@ -73,8 +73,22 @@ export function readFileForIndex(filePath) {
73
73
  }
74
74
 
75
75
  export async function readTextFile(filePath, maxBytes = 2 * 1024 * 1024) {
76
+ // 先 stat 再读:大小上限的意义就是"别把整个文件读进内存"。旧写法先 readFile 再比较,
77
+ // 127 MB 的文件会先把 127 MB 拉进 RSS 再抛错(watch/lazy 每轮重试,反复发生)。
78
+ if (Number.isFinite(maxBytes)) {
79
+ let size = 0
80
+ try {
81
+ size = statSync(filePath).size
82
+ } catch {
83
+ size = 0 // 交给下面的 readFile 报真实错误(不存在 / 无权限)
84
+ }
85
+ if (size > maxBytes) {
86
+ throw new Error(`File too large to index as text (${(size / 1024 / 1024).toFixed(1)} MB)`)
87
+ }
88
+ }
76
89
  const buf = await readFile(filePath)
77
90
  if (buf.length > maxBytes) {
91
+ // stat 与 read 之间文件被改大:兜底再判一次
78
92
  throw new Error(`File too large to index as text (${(buf.length / 1024 / 1024).toFixed(1)} MB)`)
79
93
  }
80
94
  return buf.toString('utf8')
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 会话级状态的容量管理。
3
+ *
4
+ * 注入引擎有六张「每会话一份」的表(注入指纹 / 丢弃签名 / 已注入条目 / 步号 / 额度 / 动作观察窗)。
5
+ * 它们各自都需要淘汰最旧会话,于是同一个 `while (map.size > CAP) delete(oldest)` 被抄了五遍——
6
+ * 容量上限写在五处,抄漏一处就是无界增长,dispose 时也漏清两张表。这里把容量收成唯一事实:
7
+ * 新增一种会话状态只需换一个 create 函数,不必再想淘汰。
8
+ */
9
+
10
+ const DEFAULT_MAX_SESSIONS = 200
11
+
12
+ /** 容量受限的 Map:写入超过 max 时按插入顺序淘汰最旧项(重写已有键会刷新它的位置)。 */
13
+ export class BoundedMap extends Map {
14
+ constructor(max, entries) {
15
+ super(entries)
16
+ this.max = max
17
+ }
18
+
19
+ set(key, value) {
20
+ if (this.has(key)) super.delete(key)
21
+ super.set(key, value)
22
+ while (this.size > this.max) super.delete(this.keys().next().value)
23
+ return this
24
+ }
25
+ }
26
+
27
+ /**
28
+ * 会话 → 值,容量按会话数封顶(淘汰最久未触碰的会话)。
29
+ * - `ensure(id)`:读不到就用 create 建一份并记下,适合数组 / 计数 / 额度这类累加器;
30
+ * - `peek(id)` / `set(id, v)`:适合只需读写的值(指纹、签名),不假设默认值。
31
+ */
32
+ export class SessionCache {
33
+ constructor({ create = () => undefined, maxSessions = DEFAULT_MAX_SESSIONS } = {}) {
34
+ this.create = create
35
+ this.store = new BoundedMap(maxSessions)
36
+ }
37
+
38
+ /** 只读查找;不存在返回 undefined(不创建)。读到即刷新其"最近使用"位置。 */
39
+ peek(sessionId) {
40
+ const value = this.store.get(sessionId)
41
+ if (value !== undefined) this.store.set(sessionId, value)
42
+ return value
43
+ }
44
+
45
+ /** 读取并(必要时)创建:create 只会在该会话第一次出现时调用。 */
46
+ ensure(sessionId) {
47
+ const value = this.store.get(sessionId)
48
+ if (value !== undefined) {
49
+ this.store.set(sessionId, value)
50
+ return value
51
+ }
52
+ const created = this.create(sessionId)
53
+ this.store.set(sessionId, created)
54
+ return created
55
+ }
56
+
57
+ set(sessionId, value) {
58
+ this.store.set(sessionId, value)
59
+ }
60
+
61
+ delete(sessionId) {
62
+ return this.store.delete(sessionId)
63
+ }
64
+
65
+ clear() {
66
+ this.store.clear()
67
+ }
68
+
69
+ get size() {
70
+ return this.store.size
71
+ }
72
+ }
package/src/watch.js CHANGED
@@ -1,10 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { isSupportedCode, isSupportedDoc, isUnwatchableRoot, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from './util/fs.js'
4
- import { buildDocEntries } from './doc-pipeline.js'
5
- import { docEntriesNeedBackfill } from './doc-index.js'
6
- import { scanSymbols } from './symbols.js'
7
- import { linkEntries } from './link.js'
3
+ import { isUnwatchableRoot, memoryRootFor, relativePath, storeKey, walkDir } from './util/fs.js'
4
+ import { OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from './index-pipeline.js'
8
5
  import { ProjectMemoryStore } from './store.js'
9
6
  import { onFileChanged, isTypeScriptFile } from './enhancer.js'
10
7
 
@@ -94,18 +91,16 @@ export class WatchManager {
94
91
  }
95
92
 
96
93
  async pollRoot(root, state) {
97
- const files = walkDir(root)
98
94
  const seen = new Set()
99
- let changed = 0
95
+ const updates = []
96
+ // rel → 本轮采集的 mtime:size。提交成功后才落进快照;CAS 失败的条目留在原地等下一轮。
97
+ const signatures = new Map()
100
98
 
101
- // First pass: collect all file info and compute hashes/entries (async work outside commit)
102
- const fileUpdates = []
103
-
104
- for (const filePath of files) {
99
+ for (const filePath of walkDir(root)) {
105
100
  const rel = storeKey(relativePath(root, filePath))
106
101
  seen.add(rel)
107
- const ext = path.extname(filePath).toLowerCase()
108
- if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
102
+ const kind = fileKind(path.extname(filePath).toLowerCase())
103
+ if (!kind) continue
109
104
 
110
105
  let stats
111
106
  try {
@@ -116,86 +111,49 @@ export class WatchManager {
116
111
  const sig = `${stats.mtimeMs}:${stats.size}`
117
112
  if (state.snapshot[rel] === sig) continue
118
113
 
119
- if (isSupportedCode(ext) && this.config.maxFileSizeMb && stats.size > this.config.maxFileSizeMb * 1024 * 1024) {
120
- continue
121
- }
122
-
123
- // 单次读盘:同一 buffer 供哈希与正文使用
124
- const { hash, buffer } = readFileForIndex(filePath)
125
- const existing = state.store.fileRecord(rel)
126
- // 旧 store 的 doc 条目缺 terms → 一次性回填(即使哈希未变)
127
- const needsBackfill = isSupportedDoc(ext) && docEntriesNeedBackfill(state.store.entries[rel])
128
- if (existing && existing.sha256 === hash && !needsBackfill) continue
129
-
114
+ const record = state.store.fileRecord(rel)
115
+ let plan
130
116
  try {
131
- let entries
132
- if (isSupportedCode(ext)) {
133
- entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
134
- } else {
135
- entries = await buildDocEntries(rel, filePath, {
136
- chunkChars: this.config.chunkChars,
137
- maxChunks: this.config.maxChunksPerFile,
138
- maxFileSizeMb: this.config.maxFileSizeMb,
139
- maxPdfPages: this.config.maxPdfPages,
140
- })
141
- if (entries === null) {
142
- // Dump file - update snapshot so we don't re-hash next poll, but don't index
143
- fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, deleted: true, _sig: sig })
144
- changed++
145
- state.failures?.delete(rel)
146
- continue
147
- }
148
- }
149
- fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, hash, size: stats.size, entries, type: isSupportedCode(ext) ? 'code' : 'doc', _sig: sig })
150
- changed++
151
- state.failures?.delete(rel)
117
+ plan = await planFileIndex({
118
+ rel,
119
+ filePath,
120
+ kind,
121
+ config: this.config,
122
+ record,
123
+ existingEntries: state.store.entries[rel],
124
+ size: stats.size,
125
+ })
152
126
  } catch (err) {
153
- // Index failed - rollback snapshot so next poll retries
154
- delete state.snapshot[rel]
155
- // 同一个文件的同一个错误只上报一次:坏文件每轮都会重试,逐轮 console.error 会刷屏
156
- if (!state.failures) state.failures = new Map()
127
+ // 坏文件每轮都会重试:同一个文件的同一个错误只上报一次,否则逐轮 console.error 刷屏。
157
128
  if (state.failures.get(rel) !== err.message) {
158
129
  state.failures.set(rel, err.message)
159
130
  console.error(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
160
131
  }
161
132
  continue
162
133
  }
163
- }
164
-
165
- // Single commit with all updates
166
- state.store.commit((s) => {
167
- for (const update of fileUpdates) {
168
- const result = s.applyFileUpdate(update.rel, update)
169
- if (result.skipped) {
170
- // CAS failed - file was modified concurrently, rollback snapshot to retry next poll
171
- delete state.snapshot[update.rel]
172
- // Mark this update to skip snapshot update after commit
173
- update._skipSnapshot = true
174
- }
175
- }
176
-
177
- // Remove deleted files
178
- for (const rel of Object.keys(s.files)) {
179
- if (!seen.has(rel)) {
180
- s.removeFile(rel)
181
- }
134
+ state.failures.delete(rel)
135
+ // 内容未变 / 体积超限也要落快照:否则 mtime:size 快路径永远命中不了,
136
+ // 每次 poll 都要把整个仓库重读一遍再哈希(watch 默认开、15s 一轮)。
137
+ if (plan.key === UNCHANGED || plan.key === OVERSIZE) {
138
+ state.snapshot[rel] = sig
139
+ continue
182
140
  }
183
141
 
184
- if (changed) {
185
- linkEntries(s)
186
- }
187
- })
142
+ signatures.set(rel, sig)
143
+ updates.push(toFileUpdate(rel, plan, record))
144
+ }
188
145
 
189
- // Trigger TS enhancement for code files (TS/JS only)
190
- for (const update of fileUpdates) {
191
- if (update._skipSnapshot) continue
192
- if (update.type === 'code') {
193
- const filePath = path.join(root, update.rel)
194
- if (isTypeScriptFile(filePath)) {
195
- onFileChanged(state.store, update.rel, filePath, this.config, root)
196
- }
146
+ // 单事务写盘;CAS 失败的条目本轮不落快照,下一轮自然重试。
147
+ const { stale } = commitFileUpdates(state.store, { updates, unseen: seen, link: updates.length > 0 })
148
+ const failed = new Set(stale)
149
+ for (const update of updates) {
150
+ if (failed.has(update.rel)) continue
151
+ state.snapshot[update.rel] = signatures.get(update.rel)
152
+ if (update.type !== 'code') continue
153
+ const filePath = path.join(root, update.rel)
154
+ if (isTypeScriptFile(filePath)) {
155
+ onFileChanged(state.store, update.rel, filePath, this.config, root)
197
156
  }
198
- state.snapshot[update.rel] = update._sig
199
157
  }
200
158
  }
201
159
  }