@yolk_vat-y/dsh-project-memory 0.3.1 → 0.3.3

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,444 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { createRequire } from 'node:module'
5
+ import { linkEntries } from './link.js'
6
+
7
+ const require = createRequire(import.meta.url)
8
+
9
+ let ts = null
10
+ let tsPath = null
11
+ let tsVersion = null
12
+
13
+ function acceptTsVersion(mod) {
14
+ if (mod && mod.version && (mod.version.startsWith('6.') || mod.version.startsWith('7.'))) {
15
+ console.warn(`[dsh-project-memory] TypeScript ${mod.version} 不受支持,回退到 L1 正则。请使用 TS 5.x 获得增强类型。`)
16
+ return null
17
+ }
18
+ return mod
19
+ }
20
+
21
+ export function initTypeScript(config) {
22
+ if (config?.enableTypeScript === false) return null
23
+ if (ts) return ts
24
+
25
+ // 1. 配置指定路径
26
+ if (config?.tsPath) {
27
+ try {
28
+ const req = createRequire(config.tsPath)
29
+ const mod = acceptTsVersion(req(config.tsPath))
30
+ if (mod) {
31
+ ts = mod
32
+ tsPath = config.tsPath
33
+ tsVersion = mod.version
34
+ return ts
35
+ }
36
+ } catch (e) {
37
+ console.warn(`[dsh-project-memory] tsPath 无效: ${config.tsPath}`)
38
+ }
39
+ }
40
+
41
+ // 2. 从用户项目 cwd 向上查找 node_modules/typescript
42
+ try {
43
+ const req = createRequire(process.cwd() + '/')
44
+ const mod = acceptTsVersion(req('typescript'))
45
+ if (mod) {
46
+ ts = mod
47
+ tsPath = req.resolve('typescript')
48
+ tsVersion = mod.version
49
+ return ts
50
+ }
51
+ } catch {}
52
+
53
+ // 3. 全局(从插件自身位置解析)
54
+ try {
55
+ const mod = acceptTsVersion(require('typescript'))
56
+ if (mod) {
57
+ ts = mod
58
+ tsPath = require.resolve('typescript')
59
+ tsVersion = mod.version
60
+ return ts
61
+ }
62
+ } catch {}
63
+
64
+ ts = null
65
+ return null
66
+ }
67
+
68
+ export function getTypeScript() {
69
+ return ts
70
+ }
71
+
72
+ export function hasTypeScript() {
73
+ return ts !== null
74
+ }
75
+
76
+ export function getTsPath() {
77
+ return tsPath
78
+ }
79
+
80
+ export function getTsVersion() {
81
+ return tsVersion
82
+ }
83
+
84
+ const PRIORITY = {
85
+ ACTIVE: 0,
86
+ RECENT: 1,
87
+ BATCH: 2,
88
+ BACKLOG: 3
89
+ }
90
+
91
+ const enhanceQueue = []
92
+ let processing = false
93
+
94
+ function getCacheDirForRoot(root, config) {
95
+ return join(root, config.memoryDir || '.dsh-project-memory', 'type-cache')
96
+ }
97
+
98
+ function getCacheKey(content) {
99
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 16)
100
+ return hash
101
+ }
102
+
103
+ async function loadTypeCache(cacheDir, key) {
104
+ const file = join(cacheDir, `${key}.json`)
105
+ if (!existsSync(file)) return null
106
+ try {
107
+ const data = JSON.parse(readFileSync(file, 'utf8'))
108
+ return data
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ async function saveTypeCache(cacheDir, key, data) {
115
+ try {
116
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true })
117
+ const file = join(cacheDir, `${key}.json`)
118
+ writeFileSync(file, JSON.stringify(data))
119
+ } catch {}
120
+ }
121
+
122
+ export function isTypeScriptFile(filePath) {
123
+ const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
124
+ return ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx' ||
125
+ ext === '.mjs' || ext === '.cjs' || ext === '.mts' || ext === '.cts'
126
+ }
127
+
128
+ export function deepParseWithTS(filePath, content) {
129
+ if (!ts) return null
130
+
131
+ // Create a compiler host that provides the source file from memory
132
+ const host = {
133
+ getSourceFile: (fileName, languageVersion, onError) => {
134
+ if (fileName === filePath) {
135
+ return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
136
+ }
137
+ return undefined
138
+ },
139
+ getDefaultLibFileName: (options) => ts.getDefaultLibFileName(options),
140
+ getDefaultLibLocation: () => ts.getDefaultLibFilePath({}),
141
+ getCanonicalFileName: (fileName) => fileName,
142
+ getCurrentDirectory: () => process.cwd(),
143
+ getNewLine: () => '\n',
144
+ useCaseSensitiveFileNames: () => true,
145
+ fileExists: (fileName) => fileName === filePath,
146
+ readFile: (fileName) => fileName === filePath ? content : undefined,
147
+ directoryExists: () => true,
148
+ getDirectories: () => [],
149
+ }
150
+
151
+ const program = ts.createProgram([filePath], {}, host)
152
+ const sourceFile = program.getSourceFile(filePath)
153
+ if (!sourceFile) return []
154
+ const checker = program.getTypeChecker()
155
+
156
+ const symbols = []
157
+
158
+ function getSignature(node) {
159
+ try {
160
+ return checker.getSignatureFromDeclaration(node)
161
+ } catch {
162
+ return undefined
163
+ }
164
+ }
165
+
166
+ function getReturnType(signature) {
167
+ if (!signature) return null
168
+ try {
169
+ return checker.getReturnTypeOfSignature(signature)
170
+ } catch {
171
+ return null
172
+ }
173
+ }
174
+
175
+ function getTypeStr(type) {
176
+ if (!type) return 'void'
177
+ try {
178
+ return checker.typeToString(type)
179
+ } catch {
180
+ return 'any'
181
+ }
182
+ }
183
+
184
+ function getLine(node) {
185
+ const pos = node.getStart()
186
+ if (pos < 0) return 1
187
+ return sourceFile.getLineAndCharacterOfPosition(pos).line + 1
188
+ }
189
+
190
+ function visit(node) {
191
+ if (ts.isFunctionDeclaration(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: 'function',
204
+ typeSig: `${generics}(${params.join(', ')}): ${returnTypeStr}`,
205
+ line: getLine(node)
206
+ })
207
+ } else if (ts.isMethodDeclaration(node) && node.name) {
208
+ const signature = getSignature(node)
209
+ const returnType = getReturnType(signature)
210
+ const typeParams = node.typeParameters?.map(tp => tp.getText()) || []
211
+ const params = node.parameters.map(p => {
212
+ const type = p.type ? getTypeStr(checker.getTypeAtLocation(p.type)) : 'any'
213
+ return `${p.name.getText()}: ${type}`
214
+ })
215
+ const returnTypeStr = getTypeStr(returnType)
216
+ const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
217
+ symbols.push({
218
+ name: node.name.getText(),
219
+ kind: 'method',
220
+ typeSig: `${generics}(${params.join(', ')}): ${returnTypeStr}`,
221
+ line: getLine(node)
222
+ })
223
+ } else if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
224
+ const parent = node.parent
225
+ if (parent && (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent))) {
226
+ const signature = getSignature(node)
227
+ const returnType = getReturnType(signature)
228
+ const typeParams = node.typeParameters?.map(tp => tp.getText()) || []
229
+ const params = node.parameters.map(p => {
230
+ const type = p.type ? getTypeStr(checker.getTypeAtLocation(p.type)) : 'any'
231
+ return `${p.name.getText()}: ${type}`
232
+ })
233
+ const returnTypeStr = getTypeStr(returnType)
234
+ const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
235
+ const name = parent.name?.getText() || '(anonymous)'
236
+ const kind = ts.isArrowFunction(node) ? 'arrow' : 'function'
237
+ symbols.push({
238
+ name,
239
+ kind,
240
+ typeSig: `${generics}(${params.join(', ')}): ${returnTypeStr}`,
241
+ line: getLine(node)
242
+ })
243
+ }
244
+ } else if (ts.isClassDeclaration(node) && node.name) {
245
+ const typeParams = node.typeParameters?.map(tp => tp.getText()) || []
246
+ const generics = typeParams.length ? `<${typeParams.join(', ')}>` : ''
247
+ symbols.push({
248
+ name: node.name.getText(),
249
+ kind: 'class',
250
+ typeSig: generics,
251
+ line: getLine(node)
252
+ })
253
+ ts.forEachChild(node, visit)
254
+ } else if (ts.isInterfaceDeclaration(node)) {
255
+ const members = node.members.map(m => {
256
+ // Skip index signatures (m.name is undefined for index signatures like [key: string]: T)
257
+ if (!m.name) return null
258
+ const type = m.type ? getTypeStr(checker.getTypeAtLocation(m.type)) : 'any'
259
+ return `${m.name.getText()}: ${type}`
260
+ }).filter(Boolean).join('; ')
261
+ symbols.push({
262
+ name: node.name.getText(),
263
+ kind: 'interface',
264
+ typeSig: `{ ${members} }`,
265
+ line: getLine(node)
266
+ })
267
+ } else if (ts.isTypeAliasDeclaration(node)) {
268
+ const typeStr = node.type ? getTypeStr(checker.getTypeAtLocation(node.type)) : 'any'
269
+ symbols.push({
270
+ name: node.name.getText(),
271
+ kind: 'type',
272
+ typeSig: `= ${typeStr}`,
273
+ line: getLine(node)
274
+ })
275
+ } else {
276
+ ts.forEachChild(node, visit)
277
+ }
278
+ }
279
+
280
+ const sf = program.getSourceFile(filePath)
281
+ if (sf) {
282
+ visit(sf)
283
+ }
284
+ return symbols
285
+ }
286
+
287
+ export function enqueueEnhance(store, relPath, filePath, priority = PRIORITY.BATCH, config, root) {
288
+ if (!ts) return Promise.resolve()
289
+
290
+ let content
291
+ try {
292
+ content = readFileSync(filePath, 'utf8')
293
+ } catch {
294
+ // File disappeared between detection and enqueue - skip silently
295
+ return Promise.resolve()
296
+ }
297
+ const cacheKey = getCacheKey(content)
298
+
299
+ // Dedupe by (relPath, contentHash) - if same content, skip; if different, replace
300
+ const existingIdx = enhanceQueue.findIndex(q => q.relPath === relPath)
301
+ if (existingIdx >= 0) {
302
+ const existing = enhanceQueue[existingIdx]
303
+ if (existing.cacheKey === cacheKey) {
304
+ // Same content, just update priority if higher
305
+ if (priority < existing.priority) {
306
+ enhanceQueue[existingIdx].priority = priority
307
+ enhanceQueue.sort((a, b) => a.priority - b.priority)
308
+ }
309
+ return existing.promise
310
+ } else {
311
+ // Content changed - replace the task
312
+ enhanceQueue[existingIdx] = { relPath, filePath, priority, cacheKey, promise: null }
313
+ // Will create new promise below
314
+ }
315
+ }
316
+
317
+ const p = (async () => {
318
+ try {
319
+ const content = readFileSync(filePath, 'utf8')
320
+ const cacheKey = getCacheKey(content)
321
+ const cacheDir = getCacheDirForRoot(root || process.cwd(), config)
322
+ const cached = await loadTypeCache(cacheDir, cacheKey)
323
+ if (cached) {
324
+ // Cache hit: persist via store.commit
325
+ await store.commit(fn => applyEnhancedSymbols(fn, relPath, cached.symbols))
326
+ return
327
+ }
328
+
329
+ const enhanced = deepParseWithTS(filePath, content)
330
+ if (enhanced?.length) {
331
+ await saveTypeCache(cacheDir, cacheKey, { symbols: enhanced })
332
+ await store.commit(fn => applyEnhancedSymbols(fn, relPath, enhanced))
333
+ }
334
+ } catch (err) {
335
+ console.warn(`[dsh-project-memory] enhance failed for ${relPath}: ${err.message}`)
336
+ } finally {
337
+ // Remove only this task's own queue entry (not a newer one for the same relPath)
338
+ const idx = enhanceQueue.findIndex(q => q.promise === p)
339
+ if (idx >= 0) enhanceQueue.splice(idx, 1)
340
+ }
341
+ })()
342
+
343
+ // Update or add to queue
344
+ const existingIdx2 = enhanceQueue.findIndex(q => q.relPath === relPath)
345
+ if (existingIdx2 >= 0) {
346
+ enhanceQueue[existingIdx2] = { relPath, filePath, priority, cacheKey, promise: p }
347
+ } else {
348
+ enhanceQueue.push({ relPath, filePath, priority, cacheKey, promise: p })
349
+ }
350
+ enhanceQueue.sort((a, b) => a.priority - b.priority)
351
+
352
+ scheduleProcess()
353
+ return p
354
+ }
355
+
356
+ function scheduleProcess() {
357
+ if (processing) return
358
+ processing = true
359
+
360
+ const processNext = async () => {
361
+ while (enhanceQueue.length > 0) {
362
+ const task = enhanceQueue.shift()
363
+ try {
364
+ await task.promise
365
+ } catch (err) {
366
+ console.warn(`[dsh-project-memory] enhance task failed: ${err.message}`)
367
+ }
368
+ await new Promise(r => setImmediate(r))
369
+ }
370
+ processing = false
371
+ }
372
+
373
+ processNext()
374
+ }
375
+
376
+ function applyEnhancedSymbols(fn, relPath, enhanced) {
377
+ // Store entries in fn.entries[relPath], not fn.files[relPath].entries
378
+ const existingEntries = fn.entries[relPath] || []
379
+ // L1 entries carry title "name (kind)" + sourceLine but no name/line fields
380
+ const nameOf = e => (e.title || '').replace(/\s*\([^)]*\)\s*$/, '')
381
+ const enhancedByLine = new Map(enhanced.map(s => [s.line, s]))
382
+ const existingKeys = new Set(existingEntries.map(e => `${nameOf(e)}#${e.sourceLine}`))
383
+
384
+ // Upgrade L1 entries in place only when TS found the same symbol on the same line
385
+ const mergedEntries = existingEntries.map(e => {
386
+ const enh = enhancedByLine.get(e.sourceLine)
387
+ if (!enh || nameOf(e) !== enh.name) return e
388
+ return {
389
+ ...e,
390
+ text: `${enh.name}${enh.typeSig} -- ${relPath}:${enh.line}`,
391
+ typeSig: enh.typeSig,
392
+ enhanced: true
393
+ }
394
+ })
395
+
396
+ // Add enhanced symbols that L1 missed; keep ids unique even when several
397
+ // symbols share one line (e.g. one-line class + method)
398
+ const usedIds = new Set(existingEntries.map(e => e.id))
399
+ const newEntries = []
400
+ for (const s of enhanced) {
401
+ if (existingKeys.has(`${s.name}#${s.line}`)) continue
402
+ const base = `${relPath.replace(/[\/:\s]/g, '_')}#${s.line}`
403
+ let id = base
404
+ if (usedIds.has(id)) id = `${base}-${s.kind}`
405
+ for (let n = 2; usedIds.has(id); n++) id = `${base}-${s.kind}-${n}`
406
+ usedIds.add(id)
407
+ newEntries.push({
408
+ id,
409
+ sourcePath: relPath,
410
+ sourceLine: s.line,
411
+ type: 'symbol',
412
+ title: `${s.name} (${s.kind})`,
413
+ keywords: [s.name, s.kind],
414
+ text: `${s.name}${s.typeSig} -- ${relPath}:${s.line}`,
415
+ enhanced: true
416
+ })
417
+ }
418
+
419
+ // Write to store.entries via setEntries so the shard is marked dirty and persisted
420
+ fn.setEntries(relPath, [...mergedEntries, ...newEntries])
421
+
422
+ // Refresh doc<->symbol links for newly added symbols
423
+ if (newEntries.length) linkEntries(fn)
424
+
425
+ // Also update fn.files metadata
426
+ if (fn.files[relPath]) {
427
+ fn.files[relPath].enhanced = true
428
+ }
429
+ }
430
+
431
+ export function onFileObserved(store, relPath, filePath, config, root) {
432
+ if (!isTypeScriptFile(filePath)) return
433
+ enqueueEnhance(store, relPath, filePath, PRIORITY.ACTIVE, config, root)
434
+ }
435
+
436
+ export function onFileChanged(store, relPath, filePath, config, root) {
437
+ if (!isTypeScriptFile(filePath)) return
438
+ enqueueEnhance(store, relPath, filePath, PRIORITY.RECENT, config, root)
439
+ }
440
+
441
+ export function onFileIndexed(store, relPath, filePath, config, root) {
442
+ if (!isTypeScriptFile(filePath)) return
443
+ enqueueEnhance(store, relPath, filePath, PRIORITY.BATCH, config, root)
444
+ }
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ import { watchRepoTool } from './tools/watch-repo.js'
8
8
  import { statsTool } from './tools/stats.js'
9
9
  import { WatchManager } from './watch.js'
10
10
  import { setupLazyIndexing } from './lazy.js'
11
+ import { initTypeScript } from './enhancer.js'
11
12
 
12
13
  export const name = 'dsh-project-memory'
13
14
  export const inject = ['llm', 'tools']
@@ -25,9 +26,16 @@ export const Config = Schema.object({
25
26
  autoIndexOnFirstUse: Schema.boolean().default(false),
26
27
  watch: Schema.boolean().default(true),
27
28
  watchInterval: Schema.number().default(15),
29
+ tsPath: Schema.string(),
30
+ enableTypeScript: Schema.boolean().default(true),
28
31
  })
29
32
 
30
33
  export function apply(ctx, config) {
34
+ // Initialize TypeScript enhancer if enabled
35
+ if (config.enableTypeScript !== false) {
36
+ initTypeScript(config)
37
+ }
38
+
31
39
  const watchManager = new WatchManager(ctx, config)
32
40
  if (config.watch) {
33
41
  watchManager.restorePersisted()
package/src/lazy.js CHANGED
@@ -6,6 +6,7 @@ import { buildDocEntries } from './doc-pipeline.js'
6
6
  import { scanSymbols } from './symbols.js'
7
7
  import { linkEntries } from './link.js'
8
8
  import { ProjectMemoryStore } from './store.js'
9
+ import { onFileObserved } from './enhancer.js'
9
10
 
10
11
  const STRONG_MARKERS = ['.git', '.hg', '.svn']
11
12
 
@@ -87,6 +88,7 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
87
88
  const existing = store.fileRecord(rel)
88
89
  let hash
89
90
  let size
91
+ let entries
90
92
  try {
91
93
  if (isSupportedCode(ext) && config.maxFileSizeMb && statSync(filePath).size > config.maxFileSizeMb * 1024 * 1024) {
92
94
  return false
@@ -102,9 +104,9 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
102
104
  store.addWatch(root)
103
105
  }
104
106
 
105
- let entries
106
107
  if (isSupportedCode(ext)) {
107
108
  entries = scanSymbols(rel, filePath, readFileSync(filePath, 'utf8'))
109
+ onFileObserved(store, rel, filePath, config, root)
108
110
  return store.commit((s) => {
109
111
  s.markFile(rel, { sha256: hash, size, type: 'code', indexedAt: new Date().toISOString() })
110
112
  s.setEntries(rel, entries)
package/src/llm.js CHANGED
@@ -96,10 +96,15 @@ export async function expandQuery(llm, query, count = 6) {
96
96
  export async function extractDocEntry(llm, chunk, sourcePath) {
97
97
  const system =
98
98
  'You are a project-documentation indexer. Given a chunk of a project document, ' +
99
- 'return a STRICT JSON object with exactly three fields: ' +
100
- '"title" (short section title, string), "summary" (2-4 sentence dense summary of what this section covers, ' +
101
- 'mentioning concrete names, decisions and constraints), "keywords" (array of 5-10 searchable strings: ' +
102
- 'cover the document\'s own language AND English equivalents, so a query in either language can match). ' +
99
+ 'return a STRICT JSON object with exactly four fields: ' +
100
+ '"title" (short section title, string), ' +
101
+ '"summary" (3-5 sentence dense summary of what this section covers, ' +
102
+ 'mentioning concrete names, decisions, constraints, and key technical details), ' +
103
+ '"blindSpots" (string describing what this summary does NOT cover, ' +
104
+ 'e.g. "未覆盖:部署细节、性能基准、v0.2 前 API", empty string if none), ' +
105
+ '"keywords" (array of 5-10 searchable strings: ' +
106
+ 'cover the document\'s own language AND English equivalents, ' +
107
+ 'so a query in either language can match). ' +
103
108
  'Do not include markdown fences, do not add commentary, output only the JSON object.'
104
109
 
105
110
  const user =
@@ -109,6 +114,7 @@ export async function extractDocEntry(llm, chunk, sourcePath) {
109
114
  const fallback = () => ({
110
115
  title: chunk.title || sourcePath,
111
116
  summary: summarizeText(chunk.text),
117
+ blindSpots: '',
112
118
  keywords: tokenize(chunk.title).slice(0, 5),
113
119
  })
114
120
 
@@ -121,7 +127,8 @@ export async function extractDocEntry(llm, chunk, sourcePath) {
121
127
  const kw = Array.isArray(parsed.keywords) ? parsed.keywords.map(String).filter((k) => k).slice(0, 8) : []
122
128
  return {
123
129
  title: typeof parsed.title === 'string' && parsed.title.trim() ? parsed.title.trim() : chunk.title || sourcePath,
124
- summary: summarizeText(parsed.summary),
130
+ summary: summarizeText(parsed.summary.trim()),
131
+ blindSpots: typeof parsed.blindSpots === 'string' ? parsed.blindSpots.trim() : '',
125
132
  keywords: kw.length ? kw : tokenize(chunk.title).slice(0, 5),
126
133
  }
127
134
  } catch {