@yolk_vat-y/dsh-project-memory 0.5.10 → 0.5.12
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 +257 -1
- package/LICENSE +1 -1
- package/README.md +40 -31
- package/README.zh-CN.md +40 -31
- package/cordis.patch.yml +1 -1
- package/package.json +3 -2
- package/scripts/bench-load.mjs +47 -0
- package/scripts/bench-synthetic.mjs +2 -4
- package/scripts/bench.mjs +35 -4
- package/src/audit.js +29 -3
- package/src/auto-inject.js +5 -3
- package/src/enhancer.js +181 -126
- package/src/index-pipeline.js +5 -4
- package/src/index.js +1 -1
- package/src/link.js +104 -38
- package/src/store.js +254 -25
- package/src/symbols.js +3 -1
- package/src/tools/index-doc.js +0 -2
- package/src/tools/index-repo.js +15 -6
- package/src/tools/query-memory.js +33 -13
- package/src/util/fs.js +65 -1
- package/src/util/search.js +4 -9
- package/src/util/text.js +16 -0
- package/src/watch.js +80 -18
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 在**独立进程**里量一次 store 冷加载的耗时与常驻内存,输出一行 JSON。
|
|
4
|
+
*
|
|
5
|
+
* 为什么要单独一个进程:`scripts/bench.mjs` 在同一个进程里已经有一个 store(索引用的那个),
|
|
6
|
+
* 再 load 第二个副本量出来的堆是两份的。这里从空进程冷加载,数字才是「一个 store 常驻多少」。
|
|
7
|
+
* 必须带 `--expose-gc` 运行(bench.mjs 会自己加),否则量不到稳定值。
|
|
8
|
+
*
|
|
9
|
+
* 用法:node --expose-gc scripts/bench-load.mjs <storeDir>
|
|
10
|
+
*/
|
|
11
|
+
import { performance } from 'node:perf_hooks'
|
|
12
|
+
|
|
13
|
+
import { ProjectMemoryStore } from '../src/store.js'
|
|
14
|
+
|
|
15
|
+
const dir = process.argv[2]
|
|
16
|
+
if (!dir) {
|
|
17
|
+
console.error('usage: node --expose-gc scripts/bench-load.mjs <storeDir>')
|
|
18
|
+
process.exit(2)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const gc = typeof global.gc === 'function' ? global.gc : () => {}
|
|
22
|
+
const mb = (n) => Math.round((n / 1048576) * 10) / 10
|
|
23
|
+
const snap = () => {
|
|
24
|
+
const m = process.memoryUsage()
|
|
25
|
+
return { heapMb: mb(m.heapUsed), rssMb: mb(m.rss) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
gc()
|
|
29
|
+
const t0 = performance.now()
|
|
30
|
+
const store = new ProjectMemoryStore(dir).load()
|
|
31
|
+
const loadMs = performance.now() - t0
|
|
32
|
+
gc()
|
|
33
|
+
const afterLoad = snap()
|
|
34
|
+
|
|
35
|
+
// allEntries() 会在内存里物化 searchText(派生字段不落盘),这一步才是查询前的水位
|
|
36
|
+
const entries = store.allEntries()
|
|
37
|
+
gc()
|
|
38
|
+
const afterEntries = snap()
|
|
39
|
+
|
|
40
|
+
console.log(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
loadMs: Number(loadMs.toFixed(2)),
|
|
43
|
+
entries: entries.length,
|
|
44
|
+
afterLoad,
|
|
45
|
+
afterEntries,
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - store.js : load / commit / addExperience(findSupersede) / removeExperience
|
|
6
6
|
* - util/search.js : buildBm25 / rankEntriesStreaming / rankExperienceScored
|
|
7
7
|
* - symbols.js : scanSymbols(零 token 符号抽取,无 LLM)
|
|
8
|
-
* - link.js :
|
|
8
|
+
* - link.js : resolveLinkedSymbols(doc↔symbol 交叉链接,读取期解算)
|
|
9
9
|
* - insight-store.js: GlobalStore read/write + saveInsight(归一化去重)
|
|
10
10
|
* - similarity.js : normalizedTokenOverlap(去重阈值判定)
|
|
11
11
|
*
|
|
@@ -21,7 +21,6 @@ import { performance } from 'node:perf_hooks'
|
|
|
21
21
|
import { ProjectMemoryStore } from '../src/store.js'
|
|
22
22
|
import { walkDir, readFileForIndex, relativePath, storeKey, isSupportedCode } from '../src/util/fs.js'
|
|
23
23
|
import { scanSymbols } from '../src/symbols.js'
|
|
24
|
-
import { linkEntries } from '../src/link.js'
|
|
25
24
|
import { rankEntriesStreaming, rankExperienceScored, makeSearchText } from '../src/util/search.js'
|
|
26
25
|
import { GlobalStore, saveInsight, normalizeInsight } from '../src/insight-store.js'
|
|
27
26
|
import { normalizedTokenOverlap } from '../src/similarity.js'
|
|
@@ -108,7 +107,6 @@ async function coldIndex(root, config) {
|
|
|
108
107
|
const report = store.commit((s) => {
|
|
109
108
|
for (const u of fileUpdates) s.applyFileUpdate(u.rel, u)
|
|
110
109
|
for (const rel of Object.keys(s.files)) if (!seen.has(rel)) s.removeFile(rel)
|
|
111
|
-
linkEntries(s)
|
|
112
110
|
return s.stats()
|
|
113
111
|
})
|
|
114
112
|
return { report, store }
|
|
@@ -146,7 +144,7 @@ async function main() {
|
|
|
146
144
|
console.log(`\n[语料生成] ${FILES} 文件写盘耗时 ${genMs.toFixed(0)} ms`)
|
|
147
145
|
|
|
148
146
|
// ---------- 1. 冷索引(完整内核管线,无 LLM)----------
|
|
149
|
-
console.log('\n--- 1. 冷索引 index_repo 内核管线(walk + sha256 + scanSymbols +
|
|
147
|
+
console.log('\n--- 1. 冷索引 index_repo 内核管线(walk + sha256 + scanSymbols + commit)---')
|
|
150
148
|
const idxTimes = []
|
|
151
149
|
let entriesCount = 0
|
|
152
150
|
for (let r = 0; r < 3; r++) {
|
package/scripts/bench.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* 独立基准:给一个真实项目路径,量「冷索引 / 冷加载 / 热查询 / 单文件热重索引 /
|
|
3
|
+
* 独立基准:给一个真实项目路径,量「冷索引 / 冷加载 / 热查询 / 单文件热重索引 / 存储体积 / 常驻内存」。
|
|
4
4
|
*
|
|
5
5
|
* 特点:
|
|
6
6
|
* - **不需要运行 dsh**,只 import 插件自己的 src 模块,走的就是线上那条索引与检索路径;
|
|
7
7
|
* - **不碰被测项目**:结果写进临时目录,跑完删除(`--keep` 可保留);
|
|
8
8
|
* - 索引期零模型调用、零网络请求,所以在任何机器上都能复现。
|
|
9
|
+
* - 常驻内存(load 后 / allEntries() 后)由一个 `--expose-gc` 的子进程量(`scripts/bench-load.mjs`),
|
|
10
|
+
* 否则同一个进程里已经有索引用的那个 store,量出来是两份。
|
|
9
11
|
*
|
|
10
12
|
* 用法:
|
|
11
13
|
* node scripts/bench.mjs [projectPath] [--json] [--samples 100] [--no-pdf] [--keep] [--max-files 20000]
|
|
@@ -15,10 +17,12 @@
|
|
|
15
17
|
* [{ "query": "jwt token validation", "expect": "src/utils/auth.ts" }, ...]
|
|
16
18
|
* `expect` 按子串匹配结果的 sourcePath / title / id(大小写不敏感)。
|
|
17
19
|
*/
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
18
21
|
import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
19
22
|
import os from 'node:os'
|
|
20
23
|
import path from 'node:path'
|
|
21
24
|
import { performance } from 'node:perf_hooks'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
22
26
|
|
|
23
27
|
import { ProjectMemoryStore } from '../src/store.js'
|
|
24
28
|
import { buildDocEntries } from '../src/doc-pipeline.js'
|
|
@@ -94,6 +98,7 @@ function stats(xs) {
|
|
|
94
98
|
|
|
95
99
|
function dirSize(dir) {
|
|
96
100
|
let bytes = 0
|
|
101
|
+
let allocated = 0
|
|
97
102
|
let files = 0
|
|
98
103
|
const stack = [dir]
|
|
99
104
|
while (stack.length) {
|
|
@@ -108,7 +113,11 @@ function dirSize(dir) {
|
|
|
108
113
|
if (e.isDirectory()) stack.push(p)
|
|
109
114
|
else {
|
|
110
115
|
try {
|
|
111
|
-
|
|
116
|
+
const st = statSync(p)
|
|
117
|
+
bytes += st.size
|
|
118
|
+
// 一个源文件一个碎分片 → 每个文件至少占一个块(通常 4 KB),`du` 会明显大于
|
|
119
|
+
// 内容字节和。两个数都给出来,别把「内容大小」当成「占盘大小」。
|
|
120
|
+
allocated += st.blocks ? st.blocks * 512 : st.size
|
|
112
121
|
files++
|
|
113
122
|
} catch {
|
|
114
123
|
/* ignore */
|
|
@@ -116,7 +125,7 @@ function dirSize(dir) {
|
|
|
116
125
|
}
|
|
117
126
|
}
|
|
118
127
|
}
|
|
119
|
-
return { bytes, files }
|
|
128
|
+
return { bytes, allocated, files }
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
const kb = (n) => `${(n / 1024).toFixed(1)} KB`
|
|
@@ -206,6 +215,16 @@ const onDisk = dirSize(tmpStore)
|
|
|
206
215
|
const coldDir = `${tmpStore}-cold`
|
|
207
216
|
cpSync(tmpStore, coldDir, { recursive: true })
|
|
208
217
|
const coldLoad = timeSync(() => new ProjectMemoryStore(coldDir).load())
|
|
218
|
+
// 常驻内存必须在独立进程里量:本进程已经拿着索引那个 store,再 load 一个副本得到的是两份
|
|
219
|
+
const memory = (() => {
|
|
220
|
+
try {
|
|
221
|
+
const loadScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'bench-load.mjs')
|
|
222
|
+
const out = execFileSync(process.execPath, ['--expose-gc', loadScript, coldDir], { encoding: 'utf8' })
|
|
223
|
+
return JSON.parse(out.trim().split('\n').pop())
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { error: err.message }
|
|
226
|
+
}
|
|
227
|
+
})()
|
|
209
228
|
|
|
210
229
|
// ---------- 4. 热查询 / 冷查询 ----------
|
|
211
230
|
const queryPool = entries.filter((e) => typeof e.title === 'string' && e.title.trim().length >= 4)
|
|
@@ -328,10 +347,12 @@ const result = {
|
|
|
328
347
|
store: {
|
|
329
348
|
dir: tmpStore,
|
|
330
349
|
bytes: onDisk.bytes,
|
|
350
|
+
allocatedBytes: onDisk.allocated,
|
|
331
351
|
files: onDisk.files,
|
|
332
352
|
bytesPerEntry: entries.length ? Math.round(onDisk.bytes / entries.length) : 0,
|
|
333
353
|
},
|
|
334
354
|
coldLoadMs: round(coldLoad.ms),
|
|
355
|
+
memory,
|
|
335
356
|
query: {
|
|
336
357
|
samples: queries.length,
|
|
337
358
|
idfRebuildMs: round(idfCold.ms),
|
|
@@ -363,7 +384,17 @@ if (opts.json) {
|
|
|
363
384
|
console.log(` read+hash ${result.index.readHashMs} ms · extract ${result.index.extractMs} ms · commit ${result.index.commitMs} ms`)
|
|
364
385
|
console.log(` per file p50 ${result.index.perFileMs.p50} ms · p95 ${result.index.perFileMs.p95} ms`)
|
|
365
386
|
console.log(` note: read+hash depends on the OS page cache — run it twice and say which run you quote`)
|
|
366
|
-
console.log(
|
|
387
|
+
console.log(
|
|
388
|
+
`store ${mb(result.store.bytes)} content · ${mb(result.store.allocatedBytes)} on disk · ${result.store.bytesPerEntry} bytes/entry · cold load ${result.coldLoadMs} ms`,
|
|
389
|
+
)
|
|
390
|
+
if (memory && !memory.error) {
|
|
391
|
+
console.log(
|
|
392
|
+
`memory isolated child: after load heap ${memory.afterLoad.heapMb} MB / RSS ${memory.afterLoad.rssMb} MB` +
|
|
393
|
+
` · after allEntries() heap ${memory.afterEntries.heapMb} MB / RSS ${memory.afterEntries.rssMb} MB`,
|
|
394
|
+
)
|
|
395
|
+
} else if (memory && memory.error) {
|
|
396
|
+
console.log(`memory unavailable (${memory.error})`)
|
|
397
|
+
}
|
|
367
398
|
console.log(`\n-- query (shipped scorer: IDF cache + streaming BM25 + CJK phrase boost) --`)
|
|
368
399
|
console.log(`idf rebuild (first query after write) ${result.query.idfRebuildMs} ms`)
|
|
369
400
|
console.log(`cold query (rebuild + rank) ${result.query.coldMs} ms`)
|
package/src/audit.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// - 只在确有决策(本条真的进了上下文)时写一行:dropped 单独出现不写,否则每步刷屏;
|
|
9
9
|
// - 任何异常(目录不可写、磁盘满、记录序列化失败)一律吞掉,绝不影响宿主请求;
|
|
10
10
|
// - 单文件超限就地轮转一份 `.1`,不引入新的清理线程或后台任务。
|
|
11
|
-
import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs'
|
|
12
12
|
import path from 'node:path'
|
|
13
|
+
import { hasProjectMarker } from './util/fs.js'
|
|
13
14
|
|
|
14
15
|
export const AUDIT_FILE = 'injection-audit.jsonl'
|
|
15
16
|
const DEFAULT_MAX_BYTES = 256 * 1024
|
|
@@ -75,10 +76,34 @@ export function shadowRecordFrom(input) {
|
|
|
75
76
|
* 追加一行影子记录。返回是否写入成功(仅供测试断言)。与主审计同约定:任何异常一律吞掉,
|
|
76
77
|
* 绝不影响宿主请求。
|
|
77
78
|
*/
|
|
78
|
-
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 审计**只为"已经存在"或"结论明确"的根创建 store 目录**。
|
|
82
|
+
*
|
|
83
|
+
* 本插件里会在"什么都没索引过"的空目录里造出 `.dsh-project-memory` 的就是下面那句
|
|
84
|
+
* `mkdirSync`(`load()` 不建目录,`ensureSelfIgnore()` 也要求目录已存在)。于是任何一次
|
|
85
|
+
* "**在容器目录里**开会话"都会留下一个只装着审计日志的空 store —— 而那个目录随后还会被
|
|
86
|
+
* `resolveProjectMemoryRoot()` 当成项目根。审计是旁路,没有 store 就没有可对照的候选。
|
|
87
|
+
*
|
|
88
|
+
* 两个放行条件:
|
|
89
|
+
* - store 目录已存在 → 真被索引/写过,照旧;
|
|
90
|
+
* - 该根**自身带项目标记**(`hasProjectMarker`)→ 是"声明过的项目",即便还没落过盘,
|
|
91
|
+
* 也该留下第一步的记录(`rootNotice` 用的是同一个判据,两处口径一致)。
|
|
92
|
+
* "根是推定的(无标记)且还没有内容"→ 跳过。这正是容器目录的那种情况。
|
|
93
|
+
*
|
|
94
|
+
* @param {string} memoryDir `<root>/.dsh-project-memory`
|
|
95
|
+
* @param {string} [root] 项目根;省略时退回 `path.dirname(memoryDir)`
|
|
96
|
+
*/
|
|
97
|
+
function auditDirReady(memoryDir, root) {
|
|
98
|
+
if (existsSync(memoryDir)) return true
|
|
99
|
+
return hasProjectMarker(root || path.dirname(memoryDir))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function appendShadowAudit(memoryDir, record, cfg, root) {
|
|
79
103
|
const c = cfg || {}
|
|
80
104
|
if (c.enabled === false) return false
|
|
81
105
|
if (!memoryDir || !record) return false
|
|
106
|
+
if (!auditDirReady(memoryDir, root)) return false
|
|
82
107
|
try {
|
|
83
108
|
mkdirSync(memoryDir, { recursive: true })
|
|
84
109
|
const file = shadowFileFor(memoryDir)
|
|
@@ -124,10 +149,11 @@ export function auditRecordFrom(input) {
|
|
|
124
149
|
* @param {object} record {@link auditRecordFrom} 的产物
|
|
125
150
|
* @param {{enabled?: boolean, maxBytes?: number}} [cfg] {@link cfgAudit} 的产物
|
|
126
151
|
*/
|
|
127
|
-
export function appendInjectionAudit(memoryDir, record, cfg) {
|
|
152
|
+
export function appendInjectionAudit(memoryDir, record, cfg, root) {
|
|
128
153
|
const c = cfg || {}
|
|
129
154
|
if (c.enabled === false) return false
|
|
130
155
|
if (!memoryDir || !record) return false
|
|
156
|
+
if (!auditDirReady(memoryDir, root)) return false
|
|
131
157
|
try {
|
|
132
158
|
mkdirSync(memoryDir, { recursive: true })
|
|
133
159
|
const file = auditFileFor(memoryDir)
|
package/src/auto-inject.js
CHANGED
|
@@ -619,7 +619,7 @@ async function injectForStep({ payload, decision, config, cfg, audit, shadow, se
|
|
|
619
619
|
reasons: built.reasons,
|
|
620
620
|
candidates: built.candidates,
|
|
621
621
|
silence: silence.reason,
|
|
622
|
-
}), shadow)
|
|
622
|
+
}), shadow, root)
|
|
623
623
|
|
|
624
624
|
// 记忆根通告:根是**推定**的(会话工作目录,且该目录没有任何项目标记)时,
|
|
625
625
|
// 第一次注入前告诉模型"记忆现在存在哪、怎么改"。它是状态声明(与常驻任务卡同类),
|
|
@@ -648,7 +648,9 @@ async function injectForStep({ payload, decision, config, cfg, audit, shadow, se
|
|
|
648
648
|
if (built.reasons.length) {
|
|
649
649
|
try {
|
|
650
650
|
if (recordHit({ store, globalStore, ids: built.reasons.map((r) => r.id) })) {
|
|
651
|
-
|
|
651
|
+
// 只有这个根里真有东西才落盘。否则一次**全局** insight 的命中就会为一个从没被
|
|
652
|
+
// 索引过的容器目录建出整个 store(`save()` 会 mkdir)。审计侧同样不建,见 audit.js。
|
|
653
|
+
if (store.hasContent) store.save()
|
|
652
654
|
globalStore.commit()
|
|
653
655
|
}
|
|
654
656
|
} catch {
|
|
@@ -667,7 +669,7 @@ async function injectForStep({ payload, decision, config, cfg, audit, shadow, se
|
|
|
667
669
|
dropped: built.dropped,
|
|
668
670
|
budget: { items: quota.items, chars: quota.chars, lastStep: Number.isFinite(quota.lastStep) ? quota.lastStep : null },
|
|
669
671
|
silence: silence.reason,
|
|
670
|
-
}), audit)
|
|
672
|
+
}), audit, root)
|
|
671
673
|
|
|
672
674
|
return { ...decision, messages: [...decision.messages, injectionMessage(injectedText)] }
|
|
673
675
|
}
|