@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/CHANGELOG.md +117 -0
- package/README.md +16 -16
- package/README.zh-CN.md +16 -16
- package/cordis.patch.yml +1 -1
- package/package.json +2 -2
- package/scripts/bench-synthetic.mjs +2 -4
- package/src/enhancer.js +13 -42
- package/src/index-pipeline.js +5 -4
- package/src/index.js +1 -1
- package/src/link.js +104 -38
- package/src/store.js +172 -32
- package/src/symbols.js +3 -1
- package/src/tools/index-doc.js +0 -2
- package/src/tools/index-repo.js +3 -4
- package/src/tools/query-memory.js +7 -10
- package/src/util/fs.js +59 -20
- package/src/util/search.js +4 -9
- package/src/util/text.js +16 -0
- package/src/watch.js +40 -12
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
|
-
* -
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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 && !
|
|
515
|
+
if (session && !isUnsafe(session) && isInside(session, abs)) return session
|
|
477
516
|
return null
|
|
478
517
|
}
|
|
479
518
|
|
package/src/util/search.js
CHANGED
|
@@ -141,15 +141,10 @@ export function makeSearchText(entry) {
|
|
|
141
141
|
return weightedFieldText(entry).toLowerCase()
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 =
|
|
82
|
+
start(intervalMs = 30000) {
|
|
83
83
|
if (this.timer) return
|
|
84
|
-
// NaN/undefined
|
|
85
|
-
// 足以打满事件循环让 agent
|
|
84
|
+
// NaN/undefined 会让定时器退化成 1ms 轮询(Node 只发一条 TimeoutNaNWarning),
|
|
85
|
+
// 足以打满事件循环让 agent 无法响应;非法值回退到默认 30s。
|
|
86
86
|
const raw = Number(intervalMs)
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
}
|