@yolk_vat-y/dsh-project-memory 0.5.5 → 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.
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'
@@ -35,16 +35,38 @@ export function lessonTool(config) {
35
35
  type: 'object',
36
36
  additionalProperties: false,
37
37
  properties: {
38
- keywords: { type: 'array', items: { type: 'string' } },
38
+ when: {
39
+ type: 'object',
40
+ additionalProperties: false,
41
+ properties: {
42
+ ops: { type: 'array', items: { type: 'string' }, description: '归一动作 id:file-write / file-delete / shell-run / git-commit / git-push / release / npm-publish / deploy / migrate / render-doc / index-doc / read-image / fetch-web / run-bench / query-memory。' },
43
+ writes: { type: 'array', items: { type: 'string' }, description: '本次要【写】的项目相对路径(禁扩展名/泛名 glob)。' },
44
+ intents: { type: 'array', items: { type: 'string' }, description: '人类消息里剥离引用后的意图词(CJK ≥2 字、拉丁 ≥5 字符且不含 _ . /)。' },
45
+ },
46
+ description: '唯一触发面:三者取或。',
47
+ },
48
+ guard: {
49
+ type: 'object',
50
+ additionalProperties: false,
51
+ properties: {
52
+ paths: { type: 'array', items: { type: 'string' }, description: '收窄:写目标必须命中其中之一(自己不能触发)。' },
53
+ not_paths: { type: 'array', items: { type: 'string' } },
54
+ hosts: { type: 'array', items: { type: 'string' }, description: '例如 wsl(命令里出现 /mnt/* 或 powershell.exe)。' },
55
+ tags: { type: 'array', items: { type: 'string' }, description: '项目画像 tag 交集(画像未知时不过滤)。' },
56
+ },
57
+ description: '收窄条件:全部满足才注入。',
58
+ },
59
+ prevents: { type: 'string', description: '准入条件:不知道这条,这一步会做错什么。写不出来 → 不该进自动注入。' },
60
+ keywords: { type: 'array', items: { type: 'string' }, description: '【旧字段】降级为 intents 与被动召回排序。' },
39
61
  symbols: { type: 'array', items: { type: 'string' } },
40
- actions: { type: 'array', items: { type: 'string' } },
41
- paths: { type: 'array', items: { type: 'string' } },
42
- scope: { type: 'array', items: { type: 'string' } },
62
+ actions: { type: 'array', items: { type: 'string' }, description: '【旧字段】映射为 when.ops;死值丢弃并计入自检。' },
63
+ paths: { type: 'array', items: { type: 'string' }, description: '【旧字段】具体文件映射为 when.writes;扩展名/泛名 glob 丢弃。' },
64
+ scope: { type: 'array', items: { type: 'string' }, description: '【旧字段】默认忽略(值不在项目画像 tag 空间里)。' },
43
65
  },
44
66
  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 过滤。留空则只可能作为统计提示注入。',
67
+ 'authored trigger:命中即在**动手前**确定性注入。' +
68
+ 'when = 唯一触发面(ops / writes / intents 取或);guard 只能收窄;prevents 是准入条件。' +
69
+ '没有 when 的条目不会自动推送,只出现在记忆目录里供按需拉取。',
48
70
  },
49
71
  task_id: { type: 'string', description: '目标任务 id(scope 缺省时优先于绑定任务)' },
50
72
  files: { type: 'array', items: { type: 'string' }, description: '关联文件(项目相对路径)' },
@@ -55,6 +77,7 @@ export function lessonTool(config) {
55
77
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
56
78
  async execute(args, exec) {
57
79
  const root = resolveIndexRoot(exec, args.root)
80
+ assertIndexRoot(root, args.root || root)
58
81
  const memoryDir = memoryRootFor(root, config.memoryDir)
59
82
  const store = new ProjectMemoryStore(memoryDir).load()
60
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
+ }