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

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
@@ -209,12 +209,16 @@ export function memoryRootFor(indexRoot, memoryDir) {
209
209
  /**
210
210
  * 危险根目录:整体扫描/监听它们会直接吃满内存(issue #5 实测把 DSH 撑死的就是
211
211
  * 家目录与 `/opt/homebrew`)。判定是**精确匹配**:只拒绝这些目录**本身**,不拒绝
212
- * 它们的子目录(`~/Library` 拒绝,`~/Library/Mobile Documents/…/MyProj` 照常可用)。
212
+ * 它们的子目录(`~/Library` 拒绝,`~/Library/Mobile Documents/…/MyProj` 照常可用;
213
+ * `/tmp` 拒绝,`/tmp/myproj` 照常可用)。
213
214
  *
214
- * 三类都在名单里:
215
+ * 四类都在名单里:
215
216
  * - 文件系统根(含 Windows 盘符根);
216
217
  * - 家目录及其所有祖先(`/Users`、`/home`、`/`),以及家目录下的系统型子目录;
217
- * - 系统/包管理器前缀(`/usr`、`/opt`、`/opt/homebrew`、`C:\Windows` …)。
218
+ * - 每用户私有临时目录与共享/系统临时目录(`/tmp`、`/var/tmp`、`%TEMP%`、
219
+ * `%SystemRoot%\Temp` …)——macOS 的 `os.tmpdir()` 是 `/var/folders/…/T`,共享的
220
+ * `/tmp` 是**另一个**路径,两者都要拒;
221
+ * - 系统/包管理器前缀(`/usr`、`/opt`、`/opt/homebrew`、`C:\Windows` …),按平台分组。
218
222
  */
219
223
  function safeHomedir() {
220
224
  try {
@@ -224,6 +228,13 @@ function safeHomedir() {
224
228
  }
225
229
  }
226
230
 
231
+ /**
232
+ * POSIX 的**共享/系统**临时目录。刻意与 `os.tmpdir()` 分开列:macOS 上 `os.tmpdir()` 是
233
+ * `/var/folders/…/T` 这个每用户私有目录,共享的 `/tmp`(→ `/private/tmp`)根本不在名单里,
234
+ * 于是 `cd /tmp && dsh` 仍会把整个 /tmp 当项目根——和家目录是同一类问题(issue #5 补充发现)。
235
+ */
236
+ const POSIX_SHARED_TEMP = ['/tmp', '/var/tmp', '/private/tmp', '/private/var/tmp', '/var/folders', '/dev/shm']
237
+
227
238
  /**
228
239
  * 危险根集合(判定用,`isUnsafeRoot` 的底座)。
229
240
  *
@@ -233,8 +244,11 @@ function safeHomedir() {
233
244
  *
234
245
  * 系统前缀按平台分组是刻意的:`C:\opt`、`C:\usr` 在 Windows 上是**正常用户目录**,
235
246
  * 跨平台套用既会误伤,也会自相矛盾(`/usr/local` 被拒而 `/usr` 放行)。
247
+ *
248
+ * @returns {{ unsafe: Set<string>, sharedTemp: Set<string> }} `sharedTemp` 单独返回,只为了让
249
+ * 拒绝文案能说清"是共享临时目录"而不是笼统的"系统目录"——两套集合由同一次调用产出,不会漂移。
236
250
  */
237
- export function collectUnsafeRoots({
251
+ export function collectUnsafeRootSets({
238
252
  platform = process.platform,
239
253
  home = safeHomedir(),
240
254
  tmp = os.tmpdir(),
@@ -242,7 +256,8 @@ export function collectUnsafeRoots({
242
256
  pathApi = path,
243
257
  } = {}) {
244
258
  const set = new Set()
245
- const push = (p) => {
259
+ const sharedTemp = new Set()
260
+ const push = (target) => (p) => {
246
261
  if (!p || typeof p !== 'string') return
247
262
  let abs
248
263
  try {
@@ -250,10 +265,23 @@ export function collectUnsafeRoots({
250
265
  } catch {
251
266
  return
252
267
  }
253
- set.add(storeKey(abs, platform))
268
+ target.add(storeKey(abs, platform))
269
+ }
270
+ const add = push(set)
271
+ const addTemp = push(sharedTemp)
272
+
273
+ // 每用户私有临时目录 + 共享/系统临时目录,都算"共享临时目录"(拒绝文案上同类)。
274
+ addTemp(tmp)
275
+ if (platform === 'win32') {
276
+ addTemp(env.TEMP)
277
+ addTemp(env.TMP)
278
+ if (env.SystemRoot) addTemp(pathApi.join(env.SystemRoot, 'Temp'))
279
+ if (env.windir) addTemp(pathApi.join(env.windir, 'Temp'))
280
+ } else {
281
+ for (const p of POSIX_SHARED_TEMP) addTemp(p)
254
282
  }
283
+ for (const key of sharedTemp) set.add(key)
255
284
 
256
- push(tmp)
257
285
  let homeAbs = null
258
286
  try {
259
287
  homeAbs = home ? pathApi.resolve(home) : null
@@ -264,19 +292,19 @@ export function collectUnsafeRoots({
264
292
  // 家目录 + 它的所有祖先:往上任何一级当根都会把整台机器扫进来。
265
293
  let dir = homeAbs
266
294
  for (;;) {
267
- push(dir)
295
+ add(dir)
268
296
  const parent = pathApi.dirname(dir)
269
297
  if (parent === dir) break
270
298
  dir = parent
271
299
  }
272
300
  for (const sub of platform === 'win32' ? ['AppData', 'Application Data'] : ['Library', 'Applications', '.Trash']) {
273
- push(pathApi.join(homeAbs, sub))
301
+ add(pathApi.join(homeAbs, sub))
274
302
  }
275
303
  }
276
304
 
277
305
  if (platform === 'win32') {
278
306
  for (const name of ['SystemRoot', 'windir', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramData']) {
279
- push(env[name])
307
+ add(env[name])
280
308
  }
281
309
  } else {
282
310
  // POSIX 系统前缀 + 包管理器/工具链前缀。ARM Mac 的 Homebrew 是 git clone 到
@@ -289,13 +317,20 @@ export function collectUnsafeRoots({
289
317
  '/System', '/Library', '/Applications', '/private', '/cores',
290
318
  '/home/linuxbrew', '/home/linuxbrew/.linuxbrew', '/nix',
291
319
  ]) {
292
- push(p)
320
+ add(p)
293
321
  }
294
322
  }
295
- return set
323
+ return { unsafe: set, sharedTemp }
324
+ }
325
+
326
+ /** 兼容入口:只要判定集合。 */
327
+ export function collectUnsafeRoots(opts) {
328
+ return collectUnsafeRootSets(opts).unsafe
296
329
  }
297
330
 
298
- const UNSAFE_DIRS = collectUnsafeRoots()
331
+ const UNSAFE = collectUnsafeRootSets()
332
+ const UNSAFE_DIRS = UNSAFE.unsafe
333
+ const SHARED_TEMP_DIRS = UNSAFE.sharedTemp
299
334
 
300
335
  /** 判定依据文案:用于拒绝时告诉用户「为什么是它」。 */
301
336
  function unsafeReason(abs) {
@@ -309,7 +344,7 @@ function unsafeReason(abs) {
309
344
  home = null
310
345
  }
311
346
  if (home && key === home) return 'your home directory'
312
- if (key === storeKey(path.resolve(os.tmpdir()))) return 'the shared temp directory'
347
+ if (SHARED_TEMP_DIRS.has(key)) return 'the shared temp directory'
313
348
  return 'a system directory that is never a project root'
314
349
  }
315
350
 
@@ -434,11 +469,11 @@ function resolveDir(dir) {
434
469
  }
435
470
  }
436
471
 
437
- function nearestMarker(absPath, markers) {
472
+ function nearestMarker(absPath, markers, isUnsafe) {
438
473
  let dir = path.dirname(absPath)
439
474
  for (;;) {
440
475
  // 撞到家目录/系统目录边界就停:既不把边界当根,也不再向上(上面只会更宽)。
441
- if (isUnsafeRoot(dir)) return null
476
+ if (isUnsafe(dir)) return null
442
477
  for (const marker of markers) {
443
478
  if (existsSync(path.join(dir, marker))) return dir
444
479
  }
@@ -452,14 +487,18 @@ function nearestMarker(absPath, markers) {
452
487
  * 项目根推导(**只认标记与显式声明,绝不从文件路径兜底**)。
453
488
  *
454
489
  * @param {string} filePath 目标文件(也接受目录内探针文件路径)
455
- * @param {{ extraRoots?: Iterable<string>, sessionRoot?: string|null }} [opts]
490
+ * @param {{ extraRoots?: Iterable<string>, sessionRoot?: string|null, isUnsafe?: (dir: string) => boolean }} [opts]
456
491
  * `extraRoots`:显式登记过的根(watch_repo / watchManager)。
457
492
  * `sessionRoot`:会话工作目录,作为**最后**一级推定——仅当文件确实在它里面、
458
493
  * 且它是安全目录时采用。
494
+ * `isUnsafe`:危险根判定,默认 `isUnsafeRoot`。可注入是为了让"被拒前缀里有一个合法
495
+ * `.git`"这条反直觉输入能在任意平台上用一个临时目录复现(`/opt/homebrew` 正是被
496
+ * `.git` 主动选中的,不是兜底误判——issue #5 的现场复核结论)。
459
497
  * @returns {string|null}
460
498
  */
461
499
  export function findProjectRoot(filePath, opts = {}) {
462
500
  if (typeof filePath !== 'string' || !filePath) return null
501
+ const isUnsafe = typeof opts.isUnsafe === 'function' ? opts.isUnsafe : isUnsafeRoot
463
502
  let abs
464
503
  try {
465
504
  abs = path.resolve(filePath)
@@ -467,13 +506,13 @@ export function findProjectRoot(filePath, opts = {}) {
467
506
  return null
468
507
  }
469
508
  const explicit = longestRootContaining(opts.extraRoots || [], abs)
470
- if (explicit) return isUnsafeRoot(explicit) ? null : explicit
509
+ if (explicit) return isUnsafe(explicit) ? null : explicit
471
510
 
472
- const byMarker = nearestMarker(abs, VCS_MARKERS) || nearestMarker(abs, PROJECT_MARKERS)
511
+ const byMarker = nearestMarker(abs, VCS_MARKERS, isUnsafe) || nearestMarker(abs, PROJECT_MARKERS, isUnsafe)
473
512
  if (byMarker) return byMarker
474
513
 
475
514
  const session = resolveDir(opts.sessionRoot)
476
- if (session && !isUnsafeRoot(session) && isInside(session, abs)) return session
515
+ if (session && !isUnsafe(session) && isInside(session, abs)) return session
477
516
  return null
478
517
  }
479
518
 
@@ -141,15 +141,10 @@ export function makeSearchText(entry) {
141
141
  return weightedFieldText(entry).toLowerCase()
142
142
  }
143
143
 
144
- export function rankEntries(entries, query, limit = 8) {
145
- const bm25 = buildBm25(entries, weightedFieldText)
146
- const scored = bm25.score(query)
147
- return scored.slice(0, limit).map((r) => r.doc)
148
- }
149
-
150
- export function rankEntriesMerged(entries, queries, limit = 8) {
151
- return rankEntriesMergedScored(entries, queries, limit).map((r) => r.entry)
152
- }
144
+ // 说明:文档/符号的线上检索走 `rankEntriesStreaming`(recall.js),它不重建整库 BM25,
145
+ // 而是用预物化的 `searchText` + 每 store 缓存的 idf。曾经存在的 `rankEntries` /
146
+ // `rankEntriesMerged`(每次调用都 buildBm25 全库分词,70k 条目实测 p50 1.4s)已删除——
147
+ // 它们在生产路径上没有调用方,留着只会被误用。
153
148
 
154
149
  export function rankEntriesMergedScored(entries, queries, limit = 8) {
155
150
  if (!queries.length) return entries.slice(0, limit).map((entry) => ({ entry, score: 0 }))
package/src/util/text.js CHANGED
@@ -20,6 +20,22 @@ export function truncate(text, maxChars) {
20
20
 
21
21
  export const MAX_SUMMARY = 300
22
22
 
23
+ /** 符号声明行的上限。未限长的 interface 类型体会把一行撑到几 KB。 */
24
+ export const MAX_DECLARATION = 200
25
+
26
+ /**
27
+ * 符号的一行声明:压平空白并限长。实测一个真实 TS 仓库里,未限长的类型体正文让
28
+ * 符号 `text` 字段达到 5.6MB(而它当前没有任何消费者);限长后保住"一行声明"的语义,
29
+ * 又不会把代码层体积从 ~0.5% 推到 30%+。
30
+ * @param {unknown} text
31
+ * @param {number} max
32
+ * @returns {string}
33
+ */
34
+ export function oneLineDeclaration(text, max = MAX_DECLARATION) {
35
+ const flat = String(text || '').replace(/\s+/g, ' ').trim()
36
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
37
+ }
38
+
23
39
  /** 注入用短摘要:压平空白,按句子边界截断到 max 字符。纯函数,无 LLM。 */
24
40
  export function summarizeText(text, max = MAX_SUMMARY) {
25
41
  const flat = String(text || '').replace(/\s+/g, ' ').trim()
package/src/watch.js CHANGED
@@ -79,30 +79,54 @@ export class WatchManager {
79
79
  return this.roots.delete(root)
80
80
  }
81
81
 
82
- start(intervalMs = 15000) {
82
+ start(intervalMs = 30000) {
83
83
  if (this.timer) return
84
- // NaN/undefined 会让 setInterval 退化成 1ms 轮询(Node 只发一条 TimeoutNaNWarning),
85
- // 足以打满事件循环让 agent 无法响应;非法值回退到 15s。
84
+ // NaN/undefined 会让定时器退化成 1ms 轮询(Node 只发一条 TimeoutNaNWarning),
85
+ // 足以打满事件循环让 agent 无法响应;非法值回退到默认 30s。
86
86
  const raw = Number(intervalMs)
87
- const ms = Number.isFinite(raw) ? Math.max(raw, 1000) : 15000
88
- this.timer = setInterval(() => this.poll(), ms)
87
+ this._baseInterval = Number.isFinite(raw) ? Math.max(raw, 1000) : 30000
88
+ // 空闲退避:连续没有变化的轮次把间隔翻倍,最长 2 分钟一轮;任何变化立刻回到 base。
89
+ // 轮询本身是 O(树) 的 walkDir + 逐文件 stat(本仓库一轮实测 58–87ms),
90
+ // 常驻 15s 一轮意味着不管有没有改动都在磨 I/O。
91
+ this._maxInterval = Math.max(this._baseInterval, Math.min(this._baseInterval * 8, 120000))
92
+ this._interval = this._baseInterval
93
+ this._stopped = false
94
+ this._schedule()
95
+ }
96
+
97
+ _schedule() {
98
+ this.timer = setTimeout(() => this._tick(), this._interval)
89
99
  if (this.timer.unref) this.timer.unref()
90
100
  }
91
101
 
102
+ async _tick() {
103
+ this.timer = null
104
+ let changed = false
105
+ try {
106
+ changed = await this.poll()
107
+ } catch {
108
+ // poll() 内部已逐根兜底;这里保证无论发生什么都一定排下一轮,不会静默停掉 watch。
109
+ }
110
+ if (this._stopped) return // 轮询期间被 stop() 了,不要再排下一轮
111
+ this._interval = changed ? this._baseInterval : Math.min(this._interval * 2, this._maxInterval)
112
+ this._schedule()
113
+ }
114
+
92
115
  stop() {
93
- if (this.timer) clearInterval(this.timer)
116
+ this._stopped = true
117
+ if (this.timer) clearTimeout(this.timer)
94
118
  this.timer = null
95
119
  }
96
120
 
97
121
  async poll() {
98
- // setInterval 不等待上一轮:大仓库/文档 LLM 摘要让一轮 >interval 时,
99
- // 轮询会叠加成并发索引,最终打满事件循环。用重入锁让慢轮询自然跳过。
100
- if (this._polling) return
122
+ // 慢轮询期间再次进入直接跳过(递归 setTimeout 下正常不会叠加,保留作防御)。
123
+ if (this._polling) return false
101
124
  this._polling = true
125
+ let changed = false
102
126
  try {
103
127
  for (const [root, state] of this.roots) {
104
128
  try {
105
- await this.pollRoot(root, state)
129
+ if (await this.pollRoot(root, state)) changed = true
106
130
  } catch (err) {
107
131
  console.error(`[dsh-project-memory] watch poll failed for ${root}: ${err.message}`)
108
132
  }
@@ -110,6 +134,7 @@ export class WatchManager {
110
134
  } finally {
111
135
  this._polling = false
112
136
  }
137
+ return changed
113
138
  }
114
139
 
115
140
  async pollRoot(root, state) {
@@ -179,14 +204,15 @@ export class WatchManager {
179
204
  // 单事务写盘;CAS 失败的条目本轮不落快照,下一轮自然重试。
180
205
  // 截断时**不传 unseen**:没扫到的文件不等于被删了,否则一份被上限截掉的树每轮都会
181
206
  // 把自己的记忆删掉一半(先删再下轮重新索引,纯粹的抖动)。
182
- const { stale } = commitFileUpdates(state.store, {
207
+ const { stale, removed } = commitFileUpdates(state.store, {
183
208
  updates,
184
209
  unseen: truncated ? null : seen,
185
- link: updates.length > 0,
186
210
  })
187
211
  const failed = new Set(stale)
212
+ let applied = 0
188
213
  for (const update of updates) {
189
214
  if (failed.has(update.rel)) continue
215
+ applied++
190
216
  state.snapshot[update.rel] = signatures.get(update.rel)
191
217
  if (update.type !== 'code') continue
192
218
  const filePath = path.join(root, update.rel)
@@ -194,5 +220,7 @@ export class WatchManager {
194
220
  onFileChanged(state.store, update.rel, filePath, this.config, root)
195
221
  }
196
222
  }
223
+ // 有变化 → 下一轮回到 base 间隔;纯空转 → 退避。
224
+ return applied > 0 || removed > 0
197
225
  }
198
226
  }