@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.
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ export function truncate(text, maxChars) {
2
+ if (typeof text !== 'string') return String(text)
3
+ if (text.length <= maxChars) return text
4
+ return text.slice(0, maxChars) + `\n\n...[truncated at ${maxChars} chars]`
5
+ }
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
+ }