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

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/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@yolk_vat-y/dsh-project-memory",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Persistent project memory for dsh agents: index docs (PDF/Markdown/text) and code symbols into a searchable per-workspace store, recall them with cited sources, and keep experience entries (problems -> solutions) searchable on demand.",
5
+ "author": "00080000 <3388065969@qq.com>",
5
6
  "type": "module",
6
7
  "main": "src/index.js",
7
8
  "files": [
@@ -18,7 +19,7 @@
18
19
  "url": "https://github.com/00080000/dsh-project-memory.git"
19
20
  },
20
21
  "scripts": {
21
- "test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs && node test/client-icons.test.mjs && node test/client-slash.test.mjs && node test/workflow-command.test.mjs && node test/client-session-id.test.mjs && node test/task-view.test.mjs && node test/root-guards.test.mjs && node test/store-gitignore.test.mjs",
22
+ "test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs && node test/client-icons.test.mjs && node test/client-slash.test.mjs && node test/workflow-command.test.mjs && node test/client-session-id.test.mjs && node test/task-view.test.mjs && node test/root-guards.test.mjs && node test/store-gitignore.test.mjs && node test/store-cache.test.mjs && node test/enhancer.test.mjs",
22
23
  "eval:injection": "node test/injection-scenarios.test.mjs",
23
24
  "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
24
25
  "selfcheck:triggers": "node test/injection-scenarios.test.mjs --selfcheck",
@@ -48,13 +49,13 @@
48
49
  "pdfjs-dist": "^4.10.38"
49
50
  },
50
51
  "devDependencies": {
51
- "@deepseek-ai/cordis": "^4.0.2",
52
- "@deepseek-ai/dsh-client-locale": "0.1.5-rc.1",
53
- "@deepseek-ai/dsh-client-store": "0.1.5-rc.1",
54
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.1",
55
- "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.1",
56
- "@deepseek-ai/dsh-llm": "0.1.5-rc.1",
57
- "@deepseek-ai/dsh-tools": "0.1.5-rc.1",
52
+ "@deepseek-ai/cordis": "^4.0.4",
53
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
54
+ "@deepseek-ai/dsh-client-store": "0.2.0-rc.1",
55
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
56
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.1",
58
59
  "@deepseek-ai/schemastery": "^3.18.2",
59
60
  "@tsdown/css": "0.22.14",
60
61
  "@types/react": "^18.3.31",
@@ -65,8 +66,8 @@
65
66
  },
66
67
  "peerDependencies": {
67
68
  "@deepseek-ai/cordis": "^4.0.1",
68
- "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-rc.1 <0.2.0-0",
69
- "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-rc.1 <0.2.0-0",
69
+ "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
70
+ "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
70
71
  "@deepseek-ai/schemastery": "^3.18.1",
71
72
  "typescript": "^5.0.0 || ^6.0.0"
72
73
  },
@@ -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
+ )
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
  }
package/src/enhancer.js CHANGED
@@ -109,32 +109,78 @@ export function isTypeScriptFile(filePath) {
109
109
  ext === '.mjs' || ext === '.cjs' || ext === '.mts' || ext === '.cts'
110
110
  }
111
111
 
112
+ /**
113
+ * 路径归一化。
114
+ *
115
+ * 旧实现在 host 里用 `fileName === filePath` 认自己的文件。TypeScript 内部一律走
116
+ * `normalizePath`(`\`→`/`、消解 `.`/`..`、绝对化),而 `path.join` 给出的是平台原生形态 ——
117
+ * Windows 上是反斜杠。两边字符串不同 → `getSourceFile` 返回 undefined → `deepParseWithTS`
118
+ * 静默返回 `[]`:增强整层失效且无日志。2026-09-26 的 Windows CI 就是这样挂的
119
+ * (`test/enhancer.test.mjs` 的「增强结果落进了 store」),同一份代码在 POSIX 上从不现形。
120
+ *
121
+ * 用 TS 自己的 `normalizePath` 而不是自己拼:它才是权威口径。**该函数只在运行时导出、
122
+ * `typescript.d.ts` 里没有声明**,peer 范围覆盖 TS 5/6/7,所以必须做 `typeof` 兜底。
123
+ */
124
+ function canonicalTsPath(fileName) {
125
+ const s = String(fileName)
126
+ return typeof ts?.normalizePath === 'function' ? ts.normalizePath(s) : s.replace(/\\/g, '/')
127
+ }
128
+
129
+ /** 跟随 ts.sys 的实际大小写语义;拿不到时按平台回落。 */
130
+ function tsPathCaseSensitive() {
131
+ if (ts && ts.sys && typeof ts.sys.useCaseSensitiveFileNames === 'boolean') {
132
+ return ts.sys.useCaseSensitiveFileNames
133
+ }
134
+ return process.platform !== 'win32' && process.platform !== 'darwin'
135
+ }
136
+
112
137
  export function deepParseWithTS(filePath, content) {
113
138
  if (!ts) return null
114
139
 
140
+ const caseSensitive = tsPathCaseSensitive()
141
+ const target = caseSensitive ? canonicalTsPath(filePath) : canonicalTsPath(filePath).toLowerCase()
142
+ const isTarget = (fileName) => {
143
+ const a = canonicalTsPath(fileName)
144
+ return (caseSensitive ? a : a.toLowerCase()) === target
145
+ }
146
+ // 预建唯一的 SourceFile:它就是"这个文件"的真身,host 只需把它交出去。
147
+ // 后面按**对象身份**确认 TS 真的收下了它,不依赖 TS 内部怎么命名。
148
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
149
+
115
150
  // Create a compiler host that provides the source file from memory
116
151
  const host = {
117
- getSourceFile: (fileName, languageVersion, onError) => {
118
- if (fileName === filePath) {
119
- return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
120
- }
121
- return undefined
122
- },
152
+ getSourceFile: (fileName, languageVersion, onError) => (isTarget(fileName) ? sourceFile : undefined),
123
153
  getDefaultLibFileName: (options) => ts.getDefaultLibFileName(options),
124
- getDefaultLibLocation: () => ts.getDefaultLibFilePath({}),
125
- getCanonicalFileName: (fileName) => fileName,
154
+ getCanonicalFileName: (fileName) => (caseSensitive ? canonicalTsPath(fileName) : canonicalTsPath(fileName).toLowerCase()),
126
155
  getCurrentDirectory: () => process.cwd(),
127
156
  getNewLine: () => '\n',
128
- useCaseSensitiveFileNames: () => true,
129
- fileExists: (fileName) => fileName === filePath,
130
- readFile: (fileName) => fileName === filePath ? content : undefined,
157
+ useCaseSensitiveFileNames: () => caseSensitive,
158
+ fileExists: (fileName) => isTarget(fileName),
159
+ readFile: (fileName) => (isTarget(fileName) ? content : undefined),
131
160
  directoryExists: () => true,
132
161
  getDirectories: () => [],
133
162
  }
134
163
 
135
- const program = ts.createProgram([filePath], {}, host)
136
- const sourceFile = program.getSourceFile(filePath)
137
- if (!sourceFile) return []
164
+ // 刻意不加载默认 lib(`noLib: true`):host 手里只有这一个文件的内容,加载 lib 需要把
165
+ // lib.d.ts 及其引用的 6 个文件(lib.es5 / lib.decorators / lib.decorators.legacy / lib.dom /
166
+ // lib.webworker.importscripts / lib.scripthost,共 ~2.1MB 文本)一并读盘并重解析。
167
+ // 实测:每个 program 重解析 p50=102ms/文件;把解析好的 SourceFile 缓存跨 program 复用
168
+ // (或走 ts.DocumentRegistry)是一次性 ~100ms + 每文件 ~1ms。本版不做,代价是全局类型
169
+ // (Promise/Array/DOM)在 typeSig 里塌成 any/unknown —— 见 CHANGELOG「已知限制(本版未做)」。
170
+ // 旧实现在这里留的是 `getDefaultLibLocation: () => ts.getDefaultLibFilePath({})`,返回的是
171
+ // **文件**路径而不是目录,于是这层加载既没生效、也没人把它写下来。现在把"不载"写成声明。
172
+ const program = ts.createProgram([filePath], { allowJs: true, noLib: true }, host)
173
+ // 按**对象身份**确认 TS 收下了这个 SourceFile。`program.getSourceFile(filePath)` 也能问,
174
+ // 但它内部用 `toPath(name, cwd, getCanonicalFileName)` 拼 key,对相对路径还会拼上 cwd ——
175
+ // 正是上面那条脆弱路径。`sourceFile.parent` 恒为 undefined,不能用它判断。
176
+ if (program.getSourceFileByPath(sourceFile.path) !== sourceFile) {
177
+ // 走到这里 = host 没认出 root path(本该有结果却没有)。**刻意不写终端**:
178
+ // · 代价:watch 每 15s 一轮、每个不受支持的 root 都会撞上它,"逐轮一条"去重后仍会变成
179
+ // "每个文件一条",在真实终端里就是刷屏;
180
+ // · 收益为 0:返回 [] 只是"这轮没有增强结果",不会产出错误结果,调用方本来就按空处理。
181
+ // 这个不变量的看护在 test/enhancer.test.mjs(「路径形态」那组 + 末尾的静默契约),不靠日志。
182
+ return []
183
+ }
138
184
  const checker = program.getTypeChecker()
139
185
 
140
186
  const symbols = []
@@ -171,60 +217,79 @@ export function deepParseWithTS(filePath, content) {
171
217
  return sourceFile.getLineAndCharacterOfPosition(pos).line + 1
172
218
  }
173
219
 
220
+ /**
221
+ * 参数列表的渲染:用**源码文本**,不是 checker 的类型串。
222
+ *
223
+ * 旧实现拼 `${p.name.getText()}: ${type}`,两个副作用都被「L2 会就地重写 L1 文本」放大:
224
+ * · 无注解的参数一律写成 `: any`(本仓库 51 个 .js/.mjs 实测:86% 的参数如此),把 L1 正则
225
+ * 已经提取到的 `chunkChars = 3000` 换成纯噪音;
226
+ * · `= 默认值` / `?` / `...rest` / 解构模式全被丢掉(实测 52 个带默认值的条目里丢了 36 个)。
227
+ * 源码文本两者都保得住,还省掉每个参数一次 checker 调用。单参数限长 80,整行仍由
228
+ * oneLineDeclaration 收到 200 字符。
229
+ */
230
+ function renderParams(node) {
231
+ return node.parameters.map((p) => oneLineDeclaration(p.getText(), 80)).join(', ')
232
+ }
233
+
234
+ /**
235
+ * 返回类型只在**有信息量**时落进文本:`any`(无注解 JS 的常态)、`unknown`(未载 lib 时 async
236
+ * 的推导结果)、`{}`("任意非空值")都只会把一行声明撑长,旧实现无条件写 `): any`。
237
+ * `void` 保留 —— 它至少说明"没有返回值"。
238
+ */
239
+ function renderReturn(type) {
240
+ const s = getTypeStr(type)
241
+ return s === 'any' || s === 'unknown' || s === '{}' ? '' : `: ${s}`
242
+ }
243
+
244
+ /**
245
+ * 函数表达式/箭头函数的名字。旧白名单只有 `const x = fn` / `{ m: fn }` / `class { m = fn }`,
246
+ * 于是 CJS 最常见的 `module.exports.foo = function () {}` 产不出符号(它的父节点是
247
+ * BinaryExpression)—— 而 isTypeScriptFile() 明确把 `.cjs` 列为可增强对象,等于承诺了拿不到。
248
+ */
249
+ function callableName(node) {
250
+ const parent = node.parent
251
+ if (!parent) return null
252
+ if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent)) {
253
+ return parent.name?.getText() || '(anonymous)'
254
+ }
255
+ if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
256
+ const left = parent.left
257
+ if (ts.isPropertyAccessExpression(left)) {
258
+ // `module.exports = function () {}`(CJS 的默认导出):只取最后一段会得到名字
259
+ // "exports",既看不出它是默认导出,也容易与 `exports.foo` 混淆。保留全名。
260
+ if (ts.isIdentifier(left.expression) && left.expression.text === 'module' && left.name.text === 'exports') {
261
+ return 'module.exports'
262
+ }
263
+ return left.name.getText()
264
+ }
265
+ if (ts.isIdentifier(left)) return left.getText()
266
+ if (ts.isElementAccessExpression(left) && ts.isStringLiteralLike(left.argumentExpression)) {
267
+ return left.argumentExpression.text
268
+ }
269
+ }
270
+ return null
271
+ }
272
+
273
+ /** 三个 callable 分支共用:名字与种类由调用方给,类型串的渲染只此一处。 */
274
+ function pushCallable(node, name, kind) {
275
+ const typeParams = node.typeParameters?.map((tp) => tp.getText()) || []
276
+ const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
277
+ symbols.push({
278
+ name,
279
+ kind,
280
+ typeSig: `${generics}(${renderParams(node)})${renderReturn(getReturnType(getSignature(node)))}`,
281
+ line: getLine(node)
282
+ })
283
+ }
284
+
174
285
  function visit(node) {
175
286
  if (ts.isFunctionDeclaration(node) && node.name) {
176
- const signature = getSignature(node)
177
- const returnType = getReturnType(signature)
178
- const typeParams = node.typeParameters?.map(tp => tp.getText()) || []
179
- const params = node.parameters.map(p => {
180
- const type = p.type ? getTypeStr(checker.getTypeAtLocation(p.type)) : 'any'
181
- return `${p.name.getText()}: ${type}`
182
- })
183
- const returnTypeStr = getTypeStr(returnType)
184
- const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
185
- symbols.push({
186
- name: node.name.getText(),
187
- kind: 'function',
188
- typeSig: `${generics}(${params.join(', ')}): ${returnTypeStr}`,
189
- line: getLine(node)
190
- })
287
+ pushCallable(node, node.name.getText(), 'function')
191
288
  } else if (ts.isMethodDeclaration(node) && node.name) {
192
- const signature = getSignature(node)
193
- const returnType = getReturnType(signature)
194
- const typeParams = node.typeParameters?.map(tp => tp.getText()) || []
195
- const params = node.parameters.map(p => {
196
- const type = p.type ? getTypeStr(checker.getTypeAtLocation(p.type)) : 'any'
197
- return `${p.name.getText()}: ${type}`
198
- })
199
- const returnTypeStr = getTypeStr(returnType)
200
- const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
201
- symbols.push({
202
- name: node.name.getText(),
203
- kind: 'method',
204
- typeSig: `${generics}(${params.join(', ')}): ${returnTypeStr}`,
205
- line: getLine(node)
206
- })
289
+ pushCallable(node, node.name.getText(), 'method')
207
290
  } else if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
208
- const parent = node.parent
209
- if (parent && (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent))) {
210
- const signature = getSignature(node)
211
- const returnType = getReturnType(signature)
212
- const typeParams = node.typeParameters?.map(tp => tp.getText()) || []
213
- const params = node.parameters.map(p => {
214
- const type = p.type ? getTypeStr(checker.getTypeAtLocation(p.type)) : 'any'
215
- return `${p.name.getText()}: ${type}`
216
- })
217
- const returnTypeStr = getTypeStr(returnType)
218
- const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
219
- const name = parent.name?.getText() || '(anonymous)'
220
- const kind = ts.isArrowFunction(node) ? 'arrow' : 'function'
221
- symbols.push({
222
- name,
223
- kind,
224
- typeSig: `${generics}(${params.join(', ')}): ${returnTypeStr}`,
225
- line: getLine(node)
226
- })
227
- }
291
+ const name = callableName(node)
292
+ if (name) pushCallable(node, name, ts.isArrowFunction(node) ? 'arrow' : 'function')
228
293
  } else if (ts.isClassDeclaration(node) && node.name) {
229
294
  const typeParams = node.typeParameters?.map(tp => tp.getText()) || []
230
295
  const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
@@ -266,10 +331,9 @@ export function deepParseWithTS(filePath, content) {
266
331
  }
267
332
  }
268
333
 
269
- const sf = program.getSourceFile(filePath)
270
- if (sf) {
271
- visit(sf)
272
- }
334
+ // 用预建的那个(上面已按对象身份确认它进了 program),不再按名字回查——
335
+ // 回查依赖 TS 内部的命名,正是 Windows 上出问题的那一步。
336
+ visit(sourceFile)
273
337
  return symbols
274
338
  }
275
339
 
@@ -280,7 +344,10 @@ export function enqueueEnhance(store, relPath, filePath, priority = PRIORITY.BAT
280
344
  try {
281
345
  content = readFileSync(filePath, 'utf8')
282
346
  } catch {
283
- // File disappeared between detection and enqueue - skip silently
347
+ // 文件在"检测到变化"和"入队"之间消失了(编辑器原子保存 = 写临时文件 + rename,正好落在
348
+ // 这个窗口)。**刻意不写终端**:这不是故障而是常态,watch 每 15s 一轮还会反复重试同一个
349
+ // 文件 —— 即使按文件去重也仍会刷屏,而"这轮不增强"不会产出任何错误结果(返回 resolved,
350
+ // 与成功路径对调用方无差别)。诊断靠断言,不靠日志。
284
351
  return Promise.resolve()
285
352
  }
286
353
  const cacheKey = getCacheKey(content)
@@ -303,7 +370,23 @@ export function enqueueEnhance(store, relPath, filePath, priority = PRIORITY.BAT
303
370
  }
304
371
  }
305
372
 
306
- const p = (async () => {
373
+ // 先登记队列条目,再启动任务。
374
+ //
375
+ // 任务体可能在**同步阶段**就跑完:`deepParseWithTS` 是同步的,没有增强结果时根本走不到
376
+ // 那个 `await`,于是 `finally` 立刻执行。旧实现在任务启动**之后**才登记条目,并在 finally
377
+ // 里用 `enhanceQueue.findIndex(q => q.promise === p)` 反查自己 ——
378
+ // · 队列为空时 `findIndex` 的回调根本不会被调用,`p` 不被求值,看不出问题;
379
+ // · 队列非空(真实的 watch 轮询里必然如此)时回调被调用,读到尚未初始化的 `const p`
380
+ // → `ReferenceError: Cannot access 'p' before initialization`。
381
+ // `scheduleProcess` 会 await 这个 promise,但 watch 路径上抛出的那一个没人接,未处理的
382
+ // rejection 直接把宿主打成 `dsh: fatal load failure`。
383
+ //
384
+ // 现在:条目先入队,finally 按**对象身份**摘除自己,完全不引用尚未初始化的绑定。
385
+ const entry = { relPath, filePath, priority, cacheKey, promise: null }
386
+ if (existingIdx >= 0) enhanceQueue[existingIdx] = entry
387
+ else enhanceQueue.push(entry)
388
+
389
+ entry.promise = (async () => {
307
390
  try {
308
391
  const content = readFileSync(filePath, 'utf8')
309
392
  const enhanced = deepParseWithTS(filePath, content)
@@ -313,23 +396,15 @@ export function enqueueEnhance(store, relPath, filePath, priority = PRIORITY.BAT
313
396
  } catch (err) {
314
397
  console.warn(`[dsh-project-memory] enhance failed for ${relPath}: ${err.message}`)
315
398
  } finally {
316
- // Remove only this task's own queue entry (not a newer one for the same relPath)
317
- const idx = enhanceQueue.findIndex(q => q.promise === p)
399
+ // 只摘自己那一条:同 relPath 的新任务可能已经把它替换掉了(那种情况这里是 -1)。
400
+ const idx = enhanceQueue.indexOf(entry)
318
401
  if (idx >= 0) enhanceQueue.splice(idx, 1)
319
402
  }
320
403
  })()
321
-
322
- // Update or add to queue
323
- const existingIdx2 = enhanceQueue.findIndex(q => q.relPath === relPath)
324
- if (existingIdx2 >= 0) {
325
- enhanceQueue[existingIdx2] = { relPath, filePath, priority, cacheKey, promise: p }
326
- } else {
327
- enhanceQueue.push({ relPath, filePath, priority, cacheKey, promise: p })
328
- }
329
404
  enhanceQueue.sort((a, b) => a.priority - b.priority)
330
405
 
331
406
  scheduleProcess()
332
- return p
407
+ return entry.promise
333
408
  }
334
409
 
335
410
  function scheduleProcess() {
@@ -368,7 +443,9 @@ function applyEnhancedSymbols(fn, relPath, enhanced) {
368
443
  ...e,
369
444
  // 一行声明(限长)。typeSig 只是构建期的中间量,不落进 entry:它没有读取方,
370
445
  // 且 interface 的 typeSig 就是整个类型体,是符号条目变胖的主因。
371
- text: oneLineDeclaration(`${enh.name}${enh.typeSig} -- ${relPath}:${enh.line}`),
446
+ // 分隔符与 L1 的 buildSymbol 保持一致(` — `):L2 是**就地重写** L1 文本,
447
+ // 用不同的分隔符会让同一个文件里的条目混排两种格式。
448
+ text: oneLineDeclaration(`${enh.name}${enh.typeSig} — ${relPath}:${enh.line}`),
372
449
  typeSig: undefined,
373
450
  enhanced: true
374
451
  }
@@ -392,7 +469,7 @@ function applyEnhancedSymbols(fn, relPath, enhanced) {
392
469
  type: 'symbol',
393
470
  title: `${s.name} (${s.kind})`,
394
471
  keywords: [s.name, s.kind],
395
- text: oneLineDeclaration(`${s.name}${s.typeSig} -- ${relPath}:${s.line}`),
472
+ text: oneLineDeclaration(`${s.name}${s.typeSig} — ${relPath}:${s.line}`),
396
473
  enhanced: true
397
474
  })
398
475
  }
@@ -408,17 +485,24 @@ function applyEnhancedSymbols(fn, relPath, enhanced) {
408
485
  }
409
486
  }
410
487
 
488
+ // 三个入口都不 await 任务,所以必须各自挂一个 catch:任务体内部已经兜底,但
489
+ // **未处理的 rejection 会把整个宿主打成 `dsh: fatal load failure`**(2026-09-26 的事故就是
490
+ // 这个形状:一个 TDZ 从 watch 路径逃出去,dsh web 起不来)。这条路径不该有任何机会向上抛。
491
+ const fireAndForget = (p) => {
492
+ if (p && typeof p.catch === 'function') p.catch(() => {})
493
+ }
494
+
411
495
  export function onFileObserved(store, relPath, filePath, config, root) {
412
496
  if (!isTypeScriptFile(filePath)) return
413
- enqueueEnhance(store, relPath, filePath, PRIORITY.ACTIVE, config, root)
497
+ fireAndForget(enqueueEnhance(store, relPath, filePath, PRIORITY.ACTIVE, config, root))
414
498
  }
415
499
 
416
500
  export function onFileChanged(store, relPath, filePath, config, root) {
417
501
  if (!isTypeScriptFile(filePath)) return
418
- enqueueEnhance(store, relPath, filePath, PRIORITY.RECENT, config, root)
502
+ fireAndForget(enqueueEnhance(store, relPath, filePath, PRIORITY.RECENT, config, root))
419
503
  }
420
504
 
421
505
  export function onFileIndexed(store, relPath, filePath, config, root) {
422
506
  if (!isTypeScriptFile(filePath)) return
423
- enqueueEnhance(store, relPath, filePath, PRIORITY.BATCH, config, root)
507
+ fireAndForget(enqueueEnhance(store, relPath, filePath, PRIORITY.BATCH, config, root))
424
508
  }
package/src/llm.js CHANGED
@@ -36,7 +36,10 @@ export async function chatText(llm, system, user, { timeoutMs = 120000, route }
36
36
  } finally {
37
37
  clearTimeout(timer)
38
38
  }
39
- return textOf(assembler.message())
39
+ // 0.2.0 起 BlockAssembler.message(source) 的 source 是**必填**(Omit<ModelMessageSource,'kind'>);
40
+ // 不传在运行时仍能跑(createAssistantMessage 只做 `{kind:'model', ...source}`),但得到的
41
+ // assistant 消息会缺 provider/model 归属。我们本来就知道路由,直接如实带上。
42
+ return textOf(assembler.message({ provider: route.provider, model: route.model }))
40
43
  }
41
44
 
42
45
  export function parseStructuredJson(text) {