@yolk_vat-y/dsh-project-memory 0.1.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.
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh-CN.md +151 -0
- package/cordis.patch.yml +13 -0
- package/package.json +64 -0
- package/src/chunker.js +43 -0
- package/src/doc-pipeline.js +54 -0
- package/src/index.js +62 -0
- package/src/lazy.js +174 -0
- package/src/link.js +31 -0
- package/src/llm.js +130 -0
- package/src/parsers/pdfjs-parser.js +119 -0
- package/src/store.js +207 -0
- package/src/symbols.js +101 -0
- package/src/tools/forget.js +36 -0
- package/src/tools/index-doc.js +70 -0
- package/src/tools/index-repo.js +127 -0
- package/src/tools/query-memory.js +95 -0
- package/src/tools/remember.js +53 -0
- package/src/tools/watch-repo.js +47 -0
- package/src/util/fs.js +113 -0
- package/src/util/search.js +138 -0
- package/src/util/text.js +5 -0
- package/src/watch.js +135 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export const CJK_RANGE =
|
|
2
|
+
/[\u3400-\u9fff\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/
|
|
3
|
+
|
|
4
|
+
export function tokenizeRaw(text) {
|
|
5
|
+
if (!text) return []
|
|
6
|
+
const lower = text.toLowerCase()
|
|
7
|
+
const tokens = []
|
|
8
|
+
let cjkRun = ''
|
|
9
|
+
const flushCjk = () => {
|
|
10
|
+
if (cjkRun.length === 1) {
|
|
11
|
+
tokens.push(cjkRun)
|
|
12
|
+
} else {
|
|
13
|
+
for (let i = 0; i < cjkRun.length - 1; i++) tokens.push(cjkRun.slice(i, i + 2))
|
|
14
|
+
}
|
|
15
|
+
cjkRun = ''
|
|
16
|
+
}
|
|
17
|
+
const latin = lower.match(/[a-z0-9_]+/g) || []
|
|
18
|
+
for (const tok of latin) tokens.push(tok)
|
|
19
|
+
for (const ch of lower) {
|
|
20
|
+
if (CJK_RANGE.test(ch)) cjkRun += ch
|
|
21
|
+
else flushCjk()
|
|
22
|
+
}
|
|
23
|
+
flushCjk()
|
|
24
|
+
return tokens
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function tokenize(text) {
|
|
28
|
+
return [...new Set(tokenizeRaw(text))]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const K1 = 1.5
|
|
32
|
+
const B = 0.75
|
|
33
|
+
const DELTA = 1
|
|
34
|
+
|
|
35
|
+
function avgDocLen(docs) {
|
|
36
|
+
if (!docs.length) return 1
|
|
37
|
+
return docs.reduce((sum, d) => sum + d.length, 0) / docs.length
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildBm25(docs, getFieldText) {
|
|
41
|
+
const documents = docs.map((doc) => {
|
|
42
|
+
const text = getFieldText(doc)
|
|
43
|
+
const terms = tokenizeRaw(text)
|
|
44
|
+
const tf = {}
|
|
45
|
+
for (const t of terms) tf[t] = (tf[t] || 0) + 1
|
|
46
|
+
return { doc, length: terms.length, tf }
|
|
47
|
+
})
|
|
48
|
+
const df = {}
|
|
49
|
+
for (const d of documents) {
|
|
50
|
+
for (const t of Object.keys(d.tf)) df[t] = (df[t] || 0) + 1
|
|
51
|
+
}
|
|
52
|
+
const N = documents.length
|
|
53
|
+
const avgdl = avgDocLen(documents)
|
|
54
|
+
const idf = (t) => Math.log(1 + (N - (df[t] || 0) + 0.5) / ((df[t] || 0) + 0.5))
|
|
55
|
+
return {
|
|
56
|
+
idf,
|
|
57
|
+
score(query) {
|
|
58
|
+
const q = tokenize(query)
|
|
59
|
+
if (!q.length) return []
|
|
60
|
+
const avgLen = avgdl
|
|
61
|
+
return documents
|
|
62
|
+
.map((d) => {
|
|
63
|
+
const len = d.length || 1
|
|
64
|
+
let score = 0
|
|
65
|
+
for (const t of q) {
|
|
66
|
+
const tf = d.tf[t] || 0
|
|
67
|
+
if (!tf) continue
|
|
68
|
+
score += idf(t) * ((tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * len) / avgLen)))
|
|
69
|
+
}
|
|
70
|
+
return { doc: d.doc, score }
|
|
71
|
+
})
|
|
72
|
+
.filter((r) => r.score > 0)
|
|
73
|
+
.sort((a, b) => b.score - a.score)
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function weightedFieldText(entry) {
|
|
79
|
+
const parts = []
|
|
80
|
+
for (let i = 0; i < 5; i++) parts.push(entry.title || '')
|
|
81
|
+
parts.push((entry.keywords || []).join(' '))
|
|
82
|
+
parts.push(entry.summary || '')
|
|
83
|
+
parts.push(entry.sourcePath || '')
|
|
84
|
+
return parts.join(' ')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function rankEntries(entries, query, limit = 8) {
|
|
88
|
+
const bm25 = buildBm25(entries, weightedFieldText)
|
|
89
|
+
const scored = bm25.score(query)
|
|
90
|
+
return scored.slice(0, limit).map((r) => r.doc)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function rankEntriesMerged(entries, queries, limit = 8) {
|
|
94
|
+
return rankEntriesMergedScored(entries, queries, limit).map((r) => r.entry)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function rankEntriesMergedScored(entries, queries, limit = 8) {
|
|
98
|
+
if (!queries.length) return entries.slice(0, limit).map((entry) => ({ entry, score: 0 }))
|
|
99
|
+
const bm25 = buildBm25(entries, weightedFieldText)
|
|
100
|
+
const merged = new Map()
|
|
101
|
+
for (const query of queries) {
|
|
102
|
+
for (const r of bm25.score(query)) {
|
|
103
|
+
const id = r.doc.id || r.doc.sourcePath
|
|
104
|
+
if (merged.has(id)) {
|
|
105
|
+
if (r.score > merged.get(id).score) merged.get(id).score = r.score
|
|
106
|
+
} else {
|
|
107
|
+
merged.set(id, { entry: r.doc, score: r.score })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [...merged.values()]
|
|
112
|
+
.sort((a, b) => b.score - a.score)
|
|
113
|
+
.slice(0, limit)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function rankExperience(items, queryOrQueries, limit = 5) {
|
|
117
|
+
return rankExperienceScored(items, queryOrQueries, limit).map((r) => r.item)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function rankExperienceScored(items, queryOrQueries, limit = 5) {
|
|
121
|
+
const bm25 = buildBm25(items, (item) =>
|
|
122
|
+
`${item.problem} ${item.problem} ${item.problem} ${item.solution} ${item.sourceFile || ''}`,
|
|
123
|
+
)
|
|
124
|
+
const queries = Array.isArray(queryOrQueries) ? queryOrQueries : [queryOrQueries]
|
|
125
|
+
const merged = new Map()
|
|
126
|
+
for (const query of queries) {
|
|
127
|
+
for (const r of bm25.score(query)) {
|
|
128
|
+
if (merged.has(r.doc.id)) {
|
|
129
|
+
if (r.score > merged.get(r.doc.id).score) merged.get(r.doc.id).score = r.score
|
|
130
|
+
} else {
|
|
131
|
+
merged.set(r.doc.id, { item: r.doc, score: r.score })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [...merged.values()]
|
|
136
|
+
.sort((a, b) => b.score - a.score)
|
|
137
|
+
.slice(0, limit)
|
|
138
|
+
}
|
package/src/util/text.js
ADDED
package/src/watch.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { isSupportedCode, isSupportedDoc, memoryRootFor, relativePath, sha256OfFile, storeKey, walkDir } from './util/fs.js'
|
|
4
|
+
import { buildDocEntries } from './doc-pipeline.js'
|
|
5
|
+
import { scanSymbols } from './symbols.js'
|
|
6
|
+
import { linkEntries } from './link.js'
|
|
7
|
+
import { ProjectMemoryStore, withStoreLock } from './store.js'
|
|
8
|
+
|
|
9
|
+
export class WatchManager {
|
|
10
|
+
constructor(ctx, config) {
|
|
11
|
+
this.ctx = ctx
|
|
12
|
+
this.config = config
|
|
13
|
+
this.roots = new Map()
|
|
14
|
+
this.timer = null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
restorePersisted() {
|
|
18
|
+
const cwd = process.cwd()
|
|
19
|
+
const store = new ProjectMemoryStore(memoryRootFor(cwd, this.config.memoryDir))
|
|
20
|
+
if (existsSync(store.dir)) {
|
|
21
|
+
store.load()
|
|
22
|
+
for (const root of store.watchlist) {
|
|
23
|
+
if (typeof root === 'string' && root) this.addRoot(root)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
addRoot(root) {
|
|
29
|
+
if (!this.roots.has(root)) {
|
|
30
|
+
this.roots.set(root, {
|
|
31
|
+
store: new ProjectMemoryStore(memoryRootFor(root, this.config.memoryDir)).load(),
|
|
32
|
+
snapshot: {},
|
|
33
|
+
})
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
removeRoot(root) {
|
|
40
|
+
return this.roots.delete(root)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start(intervalMs = 15000) {
|
|
44
|
+
if (this.timer) return
|
|
45
|
+
this.timer = setInterval(() => this.poll(), Math.max(intervalMs, 1000))
|
|
46
|
+
if (this.timer.unref) this.timer.unref()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop() {
|
|
50
|
+
if (this.timer) clearInterval(this.timer)
|
|
51
|
+
this.timer = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async poll() {
|
|
55
|
+
for (const [root, state] of this.roots) {
|
|
56
|
+
try {
|
|
57
|
+
await this.pollRoot(root, state)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`[dsh-project-memory] watch poll failed for ${root}: ${err.message}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async pollRoot(root, state) {
|
|
65
|
+
const memoryDir = memoryRootFor(root, this.config.memoryDir)
|
|
66
|
+
await withStoreLock(memoryDir, async () => {
|
|
67
|
+
const files = walkDir(root)
|
|
68
|
+
const seen = new Set()
|
|
69
|
+
let changed = 0
|
|
70
|
+
|
|
71
|
+
for (const filePath of files) {
|
|
72
|
+
const rel = storeKey(relativePath(root, filePath))
|
|
73
|
+
seen.add(rel)
|
|
74
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
75
|
+
if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
|
|
76
|
+
|
|
77
|
+
let stats
|
|
78
|
+
try {
|
|
79
|
+
stats = statSync(filePath)
|
|
80
|
+
} catch {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const sig = `${stats.mtimeMs}:${stats.size}`
|
|
84
|
+
if (state.snapshot[rel] === sig) continue
|
|
85
|
+
state.snapshot[rel] = sig
|
|
86
|
+
|
|
87
|
+
if (isSupportedCode(ext) && this.config.maxFileSizeMb && stats.size > this.config.maxFileSizeMb * 1024 * 1024) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { hash } = await sha256OfFile(filePath)
|
|
92
|
+
const existing = state.store.fileRecord(rel)
|
|
93
|
+
if (existing && existing.sha256 === hash) continue
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
let entries
|
|
97
|
+
if (isSupportedCode(ext)) {
|
|
98
|
+
entries = scanSymbols(filePath, readFileSync(filePath, 'utf8'))
|
|
99
|
+
state.store.markFile(rel, { sha256: hash, size: stats.size, type: 'code', indexedAt: new Date().toISOString() })
|
|
100
|
+
} else {
|
|
101
|
+
entries = await buildDocEntries(this.ctx.llm, filePath, {
|
|
102
|
+
chunkChars: this.config.chunkChars,
|
|
103
|
+
maxChunks: this.config.maxChunksPerFile,
|
|
104
|
+
maxFileSizeMb: this.config.maxFileSizeMb,
|
|
105
|
+
maxPdfPages: this.config.maxPdfPages,
|
|
106
|
+
})
|
|
107
|
+
if (entries === null) {
|
|
108
|
+
state.store.removeFile(rel)
|
|
109
|
+
changed++
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
state.store.markFile(rel, { sha256: hash, size: stats.size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
113
|
+
}
|
|
114
|
+
state.store.setEntries(rel, entries)
|
|
115
|
+
changed++
|
|
116
|
+
} catch (err) {
|
|
117
|
+
delete state.snapshot[rel]
|
|
118
|
+
console.error(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const rel of Object.keys(state.store.files)) {
|
|
123
|
+
if (!seen.has(rel)) {
|
|
124
|
+
state.store.removeFile(rel)
|
|
125
|
+
changed++
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (changed) {
|
|
130
|
+
linkEntries(state.store)
|
|
131
|
+
state.store.save()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|