@yolk_vat-y/dsh-project-memory 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/util/fs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs'
1
+ import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { createHash } from 'node:crypto'
4
4
  import os from 'node:os'
@@ -94,17 +94,52 @@ export async function readTextFile(filePath, maxBytes = 2 * 1024 * 1024) {
94
94
  return buf.toString('utf8')
95
95
  }
96
96
 
97
+ // 逐目录名排除。除构建产物外,专门收了 macOS/Homebrew 的巨型噪音目录:
98
+ // `Library`/`Applications` 在家目录下是十万级文件,`Cellar`/`Caskroom`/`Frameworks`
99
+ // 在 /opt/homebrew 下同理——它们正是把进程内存打满的那批(issue #5)。
97
100
  const DEFAULT_IGNORE = new Set([
98
101
  '.git', 'node_modules', 'dist', 'build', '.next', 'venv', '.venv',
99
102
  '__pycache__', '.idea', '.vscode', '.dsh-project-memory', '.cache',
100
103
  'coverage', '.turbo', 'target', 'vendor', 'third_party', 'thirdparty', 'obj',
104
+ 'Library', 'Applications', 'Cellar', 'Caskroom', 'Frameworks', 'DerivedData',
105
+ 'Pods', 'bower_components', 'jspm_packages', 'logs',
101
106
  ])
102
107
 
103
- export function walkDir(root, ignoreNames = DEFAULT_IGNORE) {
108
+ /** 单次扫描的默认上限。0 / 负数 / 非数字 = 不限制。 */
109
+ export const DEFAULT_SCAN_FILES = 20000
110
+ export const DEFAULT_SCAN_DEPTH = 12
111
+
112
+ /**
113
+ * 从 config 解析扫描上限。非法值(NaN/0/负数)回落到出厂值——一个配置笔误不该
114
+ * 把保护整个关掉。显式传 `maxScanFiles: 0` 才会真的关掉(见 README)。
115
+ */
116
+ export function scanLimits(config) {
117
+ const files = Number(config?.maxScanFiles)
118
+ const depth = Number(config?.maxScanDepth)
119
+ return {
120
+ maxFiles: Number.isFinite(files) && files > 0 ? files : DEFAULT_SCAN_FILES,
121
+ maxDepth: Number.isFinite(depth) && depth > 0 ? depth : DEFAULT_SCAN_DEPTH,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 遍历目录,返回 `{ files, truncated }`。
127
+ *
128
+ * 与旧版的两点区别都是 OOM 的直接修复:
129
+ * - **有上限**:`maxFiles` / `maxDepth` 封顶,超限只做截断标记,不再一路吃内存;
130
+ * - `truncated` 让调用方区分「这棵树扫完了」和「只扫了一部分」——后者绝不能拿
131
+ * 本轮未见到的文件去删旧条目(见 index-repo/watch 的 unseen 用法)。
132
+ */
133
+ export function walkDir(root, opts = {}) {
134
+ const ignoreNames = opts.ignoreNames || DEFAULT_IGNORE
135
+ const maxFiles = Number.isFinite(opts.maxFiles) && opts.maxFiles > 0 ? opts.maxFiles : Infinity
136
+ const maxDepth = Number.isFinite(opts.maxDepth) && opts.maxDepth > 0 ? opts.maxDepth : Infinity
104
137
  const out = []
105
- const stack = [root]
138
+ const stack = [[root, 0]]
139
+ let truncated = false
140
+ let hitFileCap = false
106
141
  while (stack.length) {
107
- const dir = stack.pop()
142
+ const [dir, depth] = stack.pop()
108
143
  let entries
109
144
  try {
110
145
  entries = readdirSync(dir, { withFileTypes: true })
@@ -112,16 +147,28 @@ export function walkDir(root, ignoreNames = DEFAULT_IGNORE) {
112
147
  continue
113
148
  }
114
149
  for (const entry of entries) {
115
- if (entry.name.startsWith('.') && entry.name !== '.') continue
150
+ if (entry.name.startsWith('.')) continue
151
+ const full = path.join(dir, entry.name)
116
152
  if (entry.isDirectory()) {
117
153
  if (ignoreNames.has(entry.name)) continue
118
- stack.push(path.join(dir, entry.name))
154
+ if (depth + 1 > maxDepth) {
155
+ truncated = true
156
+ continue
157
+ }
158
+ stack.push([full, depth + 1])
119
159
  } else {
120
- out.push(path.join(dir, entry.name))
160
+ if (out.length >= maxFiles) {
161
+ truncated = true
162
+ hitFileCap = true
163
+ break
164
+ }
165
+ out.push(full)
121
166
  }
122
167
  }
168
+ if (hitFileCap) break
123
169
  }
124
- return out.sort()
170
+ out.sort()
171
+ return { files: out, truncated }
125
172
  }
126
173
 
127
174
  export function isSupportedDoc(ext) {
@@ -160,16 +207,147 @@ export function memoryRootFor(indexRoot, memoryDir) {
160
207
  }
161
208
 
162
209
  /**
163
- * 不该整体当「项目根」监听的目录:文件系统根,以及共享的 OS 临时目录。
164
- * 监听后者会把无关程序(以及插件自己的测试夹具)的临时文件全扫进来,而且只要读过
165
- * `/tmp` 下任意一个文件,findProjectRoot 的兜底就会把根解析成 `/tmp` 并自我固化。
166
- * 根的**子目录**不受影响——项目放在临时目录的子目录里照常可监听。
210
+ * 危险根目录:整体扫描/监听它们会直接吃满内存(issue #5 实测把 DSH 撑死的就是
211
+ * 家目录与 `/opt/homebrew`)。判定是**精确匹配**:只拒绝这些目录**本身**,不拒绝
212
+ * 它们的子目录(`~/Library` 拒绝,`~/Library/Mobile Documents/…/MyProj` 照常可用)。
213
+ *
214
+ * 三类都在名单里:
215
+ * - 文件系统根(含 Windows 盘符根);
216
+ * - 家目录及其所有祖先(`/Users`、`/home`、`/`),以及家目录下的系统型子目录;
217
+ * - 系统/包管理器前缀(`/usr`、`/opt`、`/opt/homebrew`、`C:\Windows` …)。
167
218
  */
168
- export function isUnwatchableRoot(root) {
219
+ function safeHomedir() {
220
+ try {
221
+ return os.homedir()
222
+ } catch {
223
+ return null
224
+ }
225
+ }
226
+
227
+ /**
228
+ * 危险根集合(判定用,`isUnsafeRoot` 的底座)。
229
+ *
230
+ * 参数化是为了**能在任意平台上验证每个平台的分支**:测试用 `path.win32` 模拟 Windows,
231
+ * 不必真的跑在 Windows 上(第一版正是因为只在 POSIX 分支里塞了 `/usr`、`/opt`,却拿它们
232
+ * 去断言 Windows,CI 才红)。生产路径只调用无参形式。
233
+ *
234
+ * 系统前缀按平台分组是刻意的:`C:\opt`、`C:\usr` 在 Windows 上是**正常用户目录**,
235
+ * 跨平台套用既会误伤,也会自相矛盾(`/usr/local` 被拒而 `/usr` 放行)。
236
+ */
237
+ export function collectUnsafeRoots({
238
+ platform = process.platform,
239
+ home = safeHomedir(),
240
+ tmp = os.tmpdir(),
241
+ env = process.env,
242
+ pathApi = path,
243
+ } = {}) {
244
+ const set = new Set()
245
+ const push = (p) => {
246
+ if (!p || typeof p !== 'string') return
247
+ let abs
248
+ try {
249
+ abs = pathApi.resolve(p)
250
+ } catch {
251
+ return
252
+ }
253
+ set.add(storeKey(abs, platform))
254
+ }
255
+
256
+ push(tmp)
257
+ let homeAbs = null
258
+ try {
259
+ homeAbs = home ? pathApi.resolve(home) : null
260
+ } catch {
261
+ homeAbs = null
262
+ }
263
+ if (homeAbs) {
264
+ // 家目录 + 它的所有祖先:往上任何一级当根都会把整台机器扫进来。
265
+ let dir = homeAbs
266
+ for (;;) {
267
+ push(dir)
268
+ const parent = pathApi.dirname(dir)
269
+ if (parent === dir) break
270
+ dir = parent
271
+ }
272
+ for (const sub of platform === 'win32' ? ['AppData', 'Application Data'] : ['Library', 'Applications', '.Trash']) {
273
+ push(pathApi.join(homeAbs, sub))
274
+ }
275
+ }
276
+
277
+ if (platform === 'win32') {
278
+ for (const name of ['SystemRoot', 'windir', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramData']) {
279
+ push(env[name])
280
+ }
281
+ } else {
282
+ // POSIX 系统前缀 + 包管理器/工具链前缀。ARM Mac 的 Homebrew 是 git clone 到
283
+ // `/opt/homebrew` 的——那里**有 `.git`**,所以单靠"只认项目标记"仍会把整个 Homebrew
284
+ // 判成项目根(issue #5 的第二个位置)。
285
+ for (const p of [
286
+ '/usr', '/usr/local', '/usr/local/Homebrew', '/usr/local/Cellar',
287
+ '/opt', '/opt/homebrew', '/opt/local',
288
+ '/etc', '/var', '/bin', '/sbin', '/dev', '/proc', '/sys', '/run',
289
+ '/System', '/Library', '/Applications', '/private', '/cores',
290
+ '/home/linuxbrew', '/home/linuxbrew/.linuxbrew', '/nix',
291
+ ]) {
292
+ push(p)
293
+ }
294
+ }
295
+ return set
296
+ }
297
+
298
+ const UNSAFE_DIRS = collectUnsafeRoots()
299
+
300
+ /** 判定依据文案:用于拒绝时告诉用户「为什么是它」。 */
301
+ function unsafeReason(abs) {
302
+ const key = storeKey(abs)
303
+ const parsed = path.parse(abs)
304
+ if (abs === parsed.root) return 'the filesystem root'
305
+ let home = null
306
+ try {
307
+ home = storeKey(path.resolve(os.homedir()))
308
+ } catch {
309
+ home = null
310
+ }
311
+ if (home && key === home) return 'your home directory'
312
+ if (key === storeKey(path.resolve(os.tmpdir()))) return 'the shared temp directory'
313
+ return 'a system directory that is never a project root'
314
+ }
315
+
316
+ /** 这个目录能否当项目记忆根?纯判定,无副作用。 */
317
+ export function isUnsafeRoot(root) {
169
318
  if (typeof root !== 'string' || !root) return true
170
- const abs = path.resolve(root)
171
- if (abs === path.parse(abs).root) return true
172
- return abs === path.resolve(os.tmpdir())
319
+ let abs
320
+ try {
321
+ abs = path.resolve(root)
322
+ } catch {
323
+ return true
324
+ }
325
+ const parsed = path.parse(abs)
326
+ if (!parsed.dir || abs === parsed.root) return true
327
+ return UNSAFE_DIRS.has(storeKey(abs))
328
+ }
329
+
330
+ export class UnsafeRootError extends Error {
331
+ constructor(root, requested) {
332
+ const target = requested && String(requested).trim() ? String(requested) : String(root)
333
+ super(
334
+ `Refusing to use ${target} as a project memory root: it is ${unsafeReason(path.resolve(root))}. ` +
335
+ 'Indexing or watching it would walk the whole directory tree (hundreds of thousands of files) and exhaust memory. ' +
336
+ 'Pass a project directory instead, or set allowUnsafeRoots: true if you really mean it.',
337
+ )
338
+ this.name = 'UnsafeRootError'
339
+ this.root = root
340
+ }
341
+ }
342
+
343
+ /**
344
+ * 校验「这个根可以当项目记忆根」。所有把目录当根写盘的入口都必须先过这一关:
345
+ * 写盘前拒绝,比事后清理 `.dsh-project-memory` 便宜得多。
346
+ */
347
+ export function assertSafeRoot(root, { requested = root, allowUnsafe = false } = {}) {
348
+ if (allowUnsafe) return root
349
+ if (!isUnsafeRoot(root)) return root
350
+ throw new UnsafeRootError(root, requested)
173
351
  }
174
352
 
175
353
  export function resolveIndexRoot(exec, explicitRoot) {
@@ -177,4 +355,161 @@ export function resolveIndexRoot(exec, explicitRoot) {
177
355
  const sessionCwd = exec?.agent?.session?.header?.cwd
178
356
  if (sessionCwd) return path.resolve(sessionCwd)
179
357
  return path.resolve(process.cwd())
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // 项目根推导 —— 全插件唯一的策略实现。
362
+ //
363
+ // 顺序(前两者是"声明",后两者是"推定"):
364
+ // 1. 显式传入的 root(工具参数);
365
+ // 2. 显式登记过的根(watch_repo / watchManager);
366
+ // 3. 最近的、含 VCS 或构建/清单标记的祖先;
367
+ // 4. 会话工作目录本身 —— 只要它是安全目录。
368
+ // 都没有 → null(该文件不落任何记忆)。
369
+ //
370
+ // 第 4 条是刻意的:`cwd` 是**人明确选择的工作位置**(在那里启动了 dsh),和"模型顺手读到的
371
+ // 某个文件所在目录"完全不是一回事。旧实现把后者也当根(兜底 `path.dirname(file)`),
372
+ // 又让 `.dsh-project-memory` 自己当标记,才把家目录锁成了项目(issue #5)。
373
+ // ---------------------------------------------------------------------------
374
+
375
+ /** VCS 边界:最强的项目根信号(`.git` 在 worktree 里是文件,existsSync 同样命中)。 */
376
+ export const VCS_MARKERS = ['.git', '.hg', '.svn']
377
+
378
+ /**
379
+ * 构建/清单标记。**刻意不含 `.dsh-project-memory`**:那个目录是插件自己创建的,
380
+ * 一旦算作项目标记就会自我固化——审计日志在会话 cwd 建出它之后,读该目录下任意文件
381
+ * 都会把根解析回该目录,于是家目录被永久锁成"项目"。
382
+ */
383
+ export const PROJECT_MARKERS = [
384
+ 'package.json', 'tsconfig.json', 'deno.json', 'deno.jsonc',
385
+ 'go.mod', 'Cargo.toml', 'pyproject.toml', 'requirements.txt', 'setup.py',
386
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts',
387
+ 'CMakeLists.txt', 'Makefile', 'composer.json', 'Gemfile', 'mix.exs', 'Package.swift',
388
+ ]
389
+
390
+ /** 探针文件名:findProjectRoot 期望文件路径,用目录内的探针路径问"这个目录的根是谁"。 */
391
+ export const PROJECT_PROBE = '__dsh-project-memory__.probe'
392
+
393
+ /** 该目录**自身**是否带项目标记(不向上找)。用于判断根是"声明的"还是"推定的"。 */
394
+ export function hasProjectMarker(dir) {
395
+ if (typeof dir !== 'string' || !dir) return false
396
+ let abs
397
+ try {
398
+ abs = path.resolve(dir)
399
+ } catch {
400
+ return false
401
+ }
402
+ for (const marker of VCS_MARKERS) if (existsSync(path.join(abs, marker))) return true
403
+ for (const marker of PROJECT_MARKERS) if (existsSync(path.join(abs, marker))) return true
404
+ return false
405
+ }
406
+
407
+ function isInside(parent, child) {
408
+ const rel = path.relative(parent, child)
409
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
410
+ }
411
+
412
+ /** 显式登记的根里,包含该文件的**最深**那一个(最长前缀匹配)。 */
413
+ function longestRootContaining(roots, absPath) {
414
+ let best = null
415
+ for (const root of roots) {
416
+ if (typeof root !== 'string' || !root) continue
417
+ let resolved
418
+ try {
419
+ resolved = path.resolve(root)
420
+ } catch {
421
+ continue
422
+ }
423
+ if (!isInside(resolved, absPath)) continue
424
+ if (!best || resolved.length > best.length) best = resolved
425
+ }
426
+ return best
427
+ }
428
+
429
+ function resolveDir(dir) {
430
+ try {
431
+ return path.resolve(dir)
432
+ } catch {
433
+ return null
434
+ }
435
+ }
436
+
437
+ function nearestMarker(absPath, markers) {
438
+ let dir = path.dirname(absPath)
439
+ for (;;) {
440
+ // 撞到家目录/系统目录边界就停:既不把边界当根,也不再向上(上面只会更宽)。
441
+ if (isUnsafeRoot(dir)) return null
442
+ for (const marker of markers) {
443
+ if (existsSync(path.join(dir, marker))) return dir
444
+ }
445
+ const parent = path.dirname(dir)
446
+ if (parent === dir) return null
447
+ dir = parent
448
+ }
449
+ }
450
+
451
+ /**
452
+ * 项目根推导(**只认标记与显式声明,绝不从文件路径兜底**)。
453
+ *
454
+ * @param {string} filePath 目标文件(也接受目录内探针文件路径)
455
+ * @param {{ extraRoots?: Iterable<string>, sessionRoot?: string|null }} [opts]
456
+ * `extraRoots`:显式登记过的根(watch_repo / watchManager)。
457
+ * `sessionRoot`:会话工作目录,作为**最后**一级推定——仅当文件确实在它里面、
458
+ * 且它是安全目录时采用。
459
+ * @returns {string|null}
460
+ */
461
+ export function findProjectRoot(filePath, opts = {}) {
462
+ if (typeof filePath !== 'string' || !filePath) return null
463
+ let abs
464
+ try {
465
+ abs = path.resolve(filePath)
466
+ } catch {
467
+ return null
468
+ }
469
+ const explicit = longestRootContaining(opts.extraRoots || [], abs)
470
+ if (explicit) return isUnsafeRoot(explicit) ? null : explicit
471
+
472
+ const byMarker = nearestMarker(abs, VCS_MARKERS) || nearestMarker(abs, PROJECT_MARKERS)
473
+ if (byMarker) return byMarker
474
+
475
+ const session = resolveDir(opts.sessionRoot)
476
+ if (session && !isUnsafeRoot(session) && isInside(session, abs)) return session
477
+ return null
478
+ }
479
+
480
+ /**
481
+ * **会话记忆根**:本插件所有"这个会话的记忆放哪"的唯一答案。
482
+ * 声明优先(标记)→ 会话工作目录(安全时)→ null(家目录/系统目录,且没有标记)。
483
+ */
484
+ export function resolveProjectMemoryRoot(cwd) {
485
+ const base = resolveDir(cwd) || resolveDir(process.cwd())
486
+ if (!base) return null
487
+ return findProjectRoot(path.join(base, PROJECT_PROBE), { sessionRoot: base })
488
+ }
489
+
490
+ /**
491
+ * 工具侧的根解析:显式 root 优先(并校验安全),否则取会话记忆根。
492
+ * 会话 cwd 是家目录且没有标记时抛 `UnsafeRootError`——旧代码会在那里建
493
+ * `.dsh-project-memory` 并把它当成项目标记,进而把整个家目录锁成"项目"(issue #5)。
494
+ */
495
+ export function resolveSafeIndexRoot(exec, explicitRoot, config) {
496
+ const allowUnsafe = config?.allowUnsafeRoots === true
497
+ const explicit = explicitRoot && String(explicitRoot).trim() ? String(explicitRoot) : null
498
+ if (explicit) {
499
+ return assertSafeRoot(path.resolve(explicit), { requested: explicit, allowUnsafe })
500
+ }
501
+ const cwd = resolveIndexRoot(exec)
502
+ const root = resolveProjectMemoryRoot(cwd)
503
+ if (root) return root
504
+ if (allowUnsafe) return cwd
505
+ throw new UnsafeRootError(cwd, cwd)
506
+ }
507
+
508
+ /** 尽力而为的会话记忆根:拿不到就返回 null(用于审计镜像这类旁路,不抛错)。 */
509
+ export function sessionMemoryRootOrNull(exec, config) {
510
+ try {
511
+ return resolveSafeIndexRoot(exec, null, config)
512
+ } catch {
513
+ return null
514
+ }
180
515
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 任务步骤的读取与展示(纯函数,零依赖,commands/ 与 tools/ 共用)。
3
+ *
4
+ * 步骤在数据里出现过三种形状:早期裸字符串、`{ text }` 时代、现在的 `{ content, status }`。
5
+ * 「取文本 / 归一状态 / 数完成度 / 相对时间 / 取 exec 会话 id」这几件事原本在
6
+ * commands/tasks.js、commands/task-actions.js、tools/query-memory.js、tools/task-tools.js、
7
+ * tools/lesson-tools.js、setup/taskbridge.js、auto-inject.js、reflection-pipeline.js 里各抄了一份,
8
+ * 并且已经漂移成两种取文本语义(`??` 与 `||`)和三种状态归一(无校验 / Set 白名单 / 数组白名单)。
9
+ * 收敛到这里,只保留一个事实:
10
+ * - 文本:content 优先,缺失才回退 text;**content 为空串不再回退**(显式清空的步骤不该顶出旧 text);
11
+ * - 状态:只认宿主 todo/write 的三个合法值,其余(含裸字符串步骤)一律 pending。
12
+ */
13
+
14
+ /** 宿主 todo/write 认得的三个状态。 */
15
+ export const STEP_STATUSES = new Set(['pending', 'in_progress', 'completed'])
16
+
17
+ /**
18
+ * 步骤文本。字符串步骤原样返回;对象步骤按 `content ?? text ?? ''` 读。
19
+ * @param {string | { content?: unknown, text?: unknown } | null | undefined} step
20
+ * @returns {string}
21
+ */
22
+ export function stepContent(step) {
23
+ if (typeof step === 'string') return step
24
+ return step?.content ?? step?.text ?? ''
25
+ }
26
+
27
+ /**
28
+ * 步骤状态:非法值、缺失值、以及无状态信息的裸字符串步骤一律归一到 `pending`。
29
+ * @param {string | { status?: unknown } | null | undefined} step
30
+ * @returns {'pending' | 'in_progress' | 'completed'}
31
+ */
32
+ export function stepStatus(step) {
33
+ if (typeof step === 'string') return 'pending'
34
+ const status = step?.status
35
+ return STEP_STATUSES.has(status) ? status : 'pending'
36
+ }
37
+
38
+ /**
39
+ * 任务进度:已完成步数 / 总步数。
40
+ * @param {{ steps?: unknown[] } | null | undefined} task
41
+ * @returns {{ done: number, total: number }}
42
+ */
43
+ export function stepProgress(task) {
44
+ const steps = task?.steps || []
45
+ let done = 0
46
+ for (const step of steps) {
47
+ if (stepStatus(step) === 'completed') done++
48
+ }
49
+ return { done, total: steps.length }
50
+ }
51
+
52
+ /** 相对时间(中文):面板、list_tasks、/tasks 三处文案共用同一套阈值。 */
53
+ export function timeAgo(iso) {
54
+ const diff = Date.now() - new Date(iso).getTime()
55
+ const m = Math.floor(diff / 60000)
56
+ if (m < 1) return '刚刚'
57
+ if (m < 60) return `${m}分钟前`
58
+ const h = Math.floor(m / 60)
59
+ if (h < 24) return `${h}小时前`
60
+ return `${Math.floor(h / 24)}天前`
61
+ }
62
+
63
+ /** 工具执行上下文里的会话 id:agent.session 优先,退化到 ctx.session。 */
64
+ export function sessionIdOf(exec) {
65
+ return exec?.agent?.session?.id || exec?.ctx?.session?.id
66
+ }
package/src/util/text.js CHANGED
@@ -1,3 +1,17 @@
1
+ /**
2
+ * 取消息里的纯文本块(多个 text 块按换行拼接)。
3
+ * 宿主消息块与流式分片都可能缺字段,这里对空块/空消息一律跳过而不是抛错。
4
+ * @param {{ content?: Array<{ type?: unknown, text?: unknown } | null> } | null | undefined} message
5
+ * @returns {string}
6
+ */
7
+ export function textOf(message) {
8
+ const blocks = (message && message.content) || []
9
+ return blocks
10
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
11
+ .map((b) => b.text)
12
+ .join('\n')
13
+ }
14
+
1
15
  export function truncate(text, maxChars) {
2
16
  if (typeof text !== 'string') return String(text)
3
17
  if (text.length <= maxChars) return text
package/src/watch.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { isUnwatchableRoot, memoryRootFor, relativePath, storeKey, walkDir } from './util/fs.js'
3
+ import { isUnsafeRoot, memoryRootFor, relativePath, scanLimits, storeKey, walkDir } from './util/fs.js'
4
4
  import { OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from './index-pipeline.js'
5
5
  import { ProjectMemoryStore } from './store.js'
6
6
  import { onFileChanged, isTypeScriptFile } from './enhancer.js'
@@ -12,10 +12,30 @@ export class WatchManager {
12
12
  this.roots = new Map()
13
13
  this.timer = null
14
14
  this._polling = false
15
+ this._truncationWarned = new Set()
16
+ }
17
+
18
+ /** 包含该路径的已注册根(最长前缀):懒索引据此把文件归到显式 watch 过的无标记项目里。 */
19
+ rootContaining(absPath) {
20
+ let best = null
21
+ for (const root of this.roots.keys()) {
22
+ const rel = path.relative(root, absPath)
23
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue
24
+ if (!best || root.length > best.length) best = root
25
+ }
26
+ return best
27
+ }
28
+
29
+ /** 危险根是否被显式放行(唯一开关,显式工具调用与 watch 共用同一判据)。 */
30
+ unsafeAllowed() {
31
+ return this.config?.allowUnsafeRoots === true
15
32
  }
16
33
 
17
34
  restorePersisted() {
18
35
  const cwd = process.cwd()
36
+ // cwd 是家目录/系统目录时直接跳过:旧顺序是「先 load 再判断」,而历史遗留的超大 store
37
+ // 光读盘就能 OOM —— 那恰恰是 issue #5 的报告者重启后会撞上的场景。
38
+ if (!this.unsafeAllowed() && isUnsafeRoot(cwd)) return
19
39
  // 用 load() 的返回值:storeCache 命中时 load() 返回的是缓存实例,忽略返回值会拿到空 store
20
40
  const store = new ProjectMemoryStore(memoryRootFor(cwd, this.config.memoryDir)).load()
21
41
  if (existsSync(store.dir)) {
@@ -23,8 +43,10 @@ export class WatchManager {
23
43
  for (const root of [...store.watchlist]) {
24
44
  if (typeof root !== 'string' || !root) continue
25
45
  // 自愈剔除两类条目:已不存在的根(每轮白跑,还会把目录重新 mkdir 出来),
26
- // 以及不该整体监听的文件系统根 / 共享临时目录(会把别人和测试的临时文件全扫进来)。
27
- if (!existsSync(root) || isUnwatchableRoot(root)) {
46
+ // 以及不该整体监听的根(文件系统根 / 家目录 / 系统与包管理器前缀)——
47
+ // 后者同时**修复历史遗留**:0.5.8 及以前被误判进去的家目录、/opt/homebrew
48
+ // 会在下一次启动时被自动摘掉,不需要用户手删 watch.json。
49
+ if (!existsSync(root) || (!this.unsafeAllowed() && isUnsafeRoot(root))) {
28
50
  store.removeWatch(root)
29
51
  dropped++
30
52
  continue
@@ -39,8 +61,8 @@ export class WatchManager {
39
61
  // 根目录不存在就拒绝:否则每轮 poll 都会 commit → save → mkdirSync,
40
62
  // 把一条历史遗留、已被删除的 watchlist 条目重新「创建」出来。
41
63
  if (!existsSync(root)) return false
42
- // 文件系统根 / 共享临时目录不整体监听(子目录允许)。
43
- if (isUnwatchableRoot(root)) return false
64
+ // 文件系统根 / 家目录 / 系统前缀不整体监听(它们的子目录允许),除非显式放行。
65
+ if (!this.unsafeAllowed() && isUnsafeRoot(root)) return false
44
66
  if (!this.roots.has(root)) {
45
67
  this.roots.set(root, {
46
68
  store: new ProjectMemoryStore(memoryRootFor(root, this.config.memoryDir)).load(),
@@ -96,7 +118,18 @@ export class WatchManager {
96
118
  // rel → 本轮采集的 mtime:size。提交成功后才落进快照;CAS 失败的条目留在原地等下一轮。
97
119
  const signatures = new Map()
98
120
 
99
- for (const filePath of walkDir(root)) {
121
+ const { files, truncated } = walkDir(root, scanLimits(this.config))
122
+ if (truncated && !this._truncationWarned.has(root)) {
123
+ // 只提示一次:这是一条**永久**的降级说明,不是每轮都要刷屏的故障。
124
+ this._truncationWarned.add(root)
125
+ const { maxFiles, maxDepth } = scanLimits(this.config)
126
+ console.error(
127
+ `[dsh-project-memory] watch scan of ${root} hit the limit (maxFiles=${maxFiles}, maxDepth=${maxDepth}); ` +
128
+ 'only part of the tree is refreshed. Raise maxScanFiles/maxScanDepth if this project is legitimately that large.',
129
+ )
130
+ }
131
+
132
+ for (const filePath of files) {
100
133
  const rel = storeKey(relativePath(root, filePath))
101
134
  seen.add(rel)
102
135
  const kind = fileKind(path.extname(filePath).toLowerCase())
@@ -144,7 +177,13 @@ export class WatchManager {
144
177
  }
145
178
 
146
179
  // 单事务写盘;CAS 失败的条目本轮不落快照,下一轮自然重试。
147
- const { stale } = commitFileUpdates(state.store, { updates, unseen: seen, link: updates.length > 0 })
180
+ // 截断时**不传 unseen**:没扫到的文件不等于被删了,否则一份被上限截掉的树每轮都会
181
+ // 把自己的记忆删掉一半(先删再下轮重新索引,纯粹的抖动)。
182
+ const { stale } = commitFileUpdates(state.store, {
183
+ updates,
184
+ unseen: truncated ? null : seen,
185
+ link: updates.length > 0,
186
+ })
148
187
  const failed = new Set(stale)
149
188
  for (const update of updates) {
150
189
  if (failed.has(update.rel)) continue