@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.
@@ -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 : linkEntries(doc↔symbol 交叉链接)
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 + linkEntries + commit)---')
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
- bytes += statSync(p).size
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(`store ${mb(result.store.bytes)} · ${result.store.bytesPerEntry} bytes/entry · cold load ${result.coldLoadMs} ms`)
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
- export function appendShadowAudit(memoryDir, record, cfg) {
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)
@@ -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
- store.save()
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
  }