@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
package/src/lazy.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
4
|
+
import { isSupportedCode, isSupportedDoc, memoryRootFor, relativePath, sha256OfFile, storeKey } from './util/fs.js'
|
|
5
|
+
import { buildDocEntries } from './doc-pipeline.js'
|
|
6
|
+
import { scanSymbols } from './symbols.js'
|
|
7
|
+
import { linkEntries } from './link.js'
|
|
8
|
+
import { ProjectMemoryStore, withStoreLock } from './store.js'
|
|
9
|
+
|
|
10
|
+
const STRONG_MARKERS = ['.git', '.hg', '.svn']
|
|
11
|
+
|
|
12
|
+
const WEAK_MARKERS = [
|
|
13
|
+
'.dsh-project-memory',
|
|
14
|
+
'package.json',
|
|
15
|
+
'go.mod',
|
|
16
|
+
'Cargo.toml',
|
|
17
|
+
'pyproject.toml',
|
|
18
|
+
'requirements.txt',
|
|
19
|
+
'pom.xml',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const SOURCE_DIR_NAMES = new Set([
|
|
23
|
+
'src', 'app', 'lib', 'libs', 'tools', 'include', 'core', 'modules',
|
|
24
|
+
'scripts', 'components', 'assets', 'utils', 'shared', 'common', 'server', 'client',
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
function looksLikeProjectRoot(dir) {
|
|
28
|
+
let hasReadmeFile = false
|
|
29
|
+
let sourceDirs = 0
|
|
30
|
+
try {
|
|
31
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
32
|
+
const name = entry.name.toLowerCase()
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (SOURCE_DIR_NAMES.has(name)) sourceDirs++
|
|
35
|
+
} else if (name.startsWith('readme')) {
|
|
36
|
+
hasReadmeFile = true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
return sourceDirs >= 2 || (hasReadmeFile && sourceDirs >= 1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sameDir(a, b) {
|
|
46
|
+
return storeKey(path.resolve(a)) === storeKey(path.resolve(b))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function findProjectRoot(filePath, ceiling = path.resolve(tmpdir())) {
|
|
50
|
+
let dir = path.dirname(filePath)
|
|
51
|
+
for (;;) {
|
|
52
|
+
if (!sameDir(dir, ceiling)) {
|
|
53
|
+
for (const marker of STRONG_MARKERS) {
|
|
54
|
+
if (existsSync(path.join(dir, marker))) return dir
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const parent = path.dirname(dir)
|
|
58
|
+
if (parent === dir || sameDir(parent, ceiling)) break
|
|
59
|
+
dir = parent
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
dir = path.dirname(filePath)
|
|
63
|
+
let best = null
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (!sameDir(dir, ceiling)) {
|
|
66
|
+
for (const marker of WEAK_MARKERS) {
|
|
67
|
+
if (existsSync(path.join(dir, marker))) return dir
|
|
68
|
+
}
|
|
69
|
+
if (!best && looksLikeProjectRoot(dir)) best = dir
|
|
70
|
+
}
|
|
71
|
+
const parent = path.dirname(dir)
|
|
72
|
+
if (parent === dir || sameDir(parent, ceiling)) break
|
|
73
|
+
dir = parent
|
|
74
|
+
}
|
|
75
|
+
return best || path.dirname(filePath)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function indexFile(ctx, config, filePath, watchManager = null) {
|
|
79
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
80
|
+
if (!isSupportedDoc(ext) && !isSupportedCode(ext)) return false
|
|
81
|
+
const root = findProjectRoot(filePath)
|
|
82
|
+
if (!root) return false
|
|
83
|
+
|
|
84
|
+
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
85
|
+
return withStoreLock(memoryDir, async () => {
|
|
86
|
+
const store = new ProjectMemoryStore(memoryDir).load()
|
|
87
|
+
const rel = storeKey(relativePath(root, filePath))
|
|
88
|
+
const existing = store.fileRecord(rel)
|
|
89
|
+
let hash
|
|
90
|
+
let size
|
|
91
|
+
try {
|
|
92
|
+
if (isSupportedCode(ext) && config.maxFileSizeMb && statSync(filePath).size > config.maxFileSizeMb * 1024 * 1024) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
;({ hash, size } = await sha256OfFile(filePath))
|
|
96
|
+
} catch {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
if (existing && existing.sha256 === hash) return false
|
|
100
|
+
|
|
101
|
+
if (watchManager) {
|
|
102
|
+
watchManager.addRoot(root)
|
|
103
|
+
store.addWatch(root)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let entries
|
|
107
|
+
if (isSupportedCode(ext)) {
|
|
108
|
+
entries = scanSymbols(filePath, readFileSync(filePath, 'utf8'))
|
|
109
|
+
store.markFile(rel, { sha256: hash, size, type: 'code', indexedAt: new Date().toISOString() })
|
|
110
|
+
} else {
|
|
111
|
+
entries = await buildDocEntries(ctx.llm, filePath, {
|
|
112
|
+
chunkChars: config.chunkChars,
|
|
113
|
+
maxChunks: config.maxChunksPerFile,
|
|
114
|
+
maxFileSizeMb: config.maxFileSizeMb,
|
|
115
|
+
maxPdfPages: config.maxPdfPages,
|
|
116
|
+
})
|
|
117
|
+
if (entries === null) {
|
|
118
|
+
store.removeFile(rel)
|
|
119
|
+
store.save()
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
|
|
123
|
+
}
|
|
124
|
+
store.setEntries(rel, entries)
|
|
125
|
+
store.save()
|
|
126
|
+
const links = linkEntries(store)
|
|
127
|
+
if (links) store.save()
|
|
128
|
+
return true
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function codeFirst(paths) {
|
|
133
|
+
return [...paths].sort((a, b) => {
|
|
134
|
+
const aCode = isSupportedCode(path.extname(a).toLowerCase()) ? 0 : 1
|
|
135
|
+
const bCode = isSupportedCode(path.extname(b).toLowerCase()) ? 0 : 1
|
|
136
|
+
return aCode - bCode
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function setupLazyIndexing(ctx, config, watchManager = null) {
|
|
141
|
+
const pending = new Map()
|
|
142
|
+
let timer = null
|
|
143
|
+
|
|
144
|
+
const flush = async () => {
|
|
145
|
+
timer = null
|
|
146
|
+
const batch = codeFirst([...pending.keys()])
|
|
147
|
+
pending.clear()
|
|
148
|
+
for (const filePath of batch) {
|
|
149
|
+
try {
|
|
150
|
+
await indexFile(ctx, config, filePath, watchManager)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error(`[dsh-project-memory] lazy index failed for ${filePath}: ${err.message}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const queue = (filePath) => {
|
|
158
|
+
pending.set(filePath, true)
|
|
159
|
+
if (!timer) {
|
|
160
|
+
timer = setTimeout(flush, 300)
|
|
161
|
+
if (timer.unref) timer.unref()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ctx.on('fs/observed', (target, observation) => {
|
|
166
|
+
if (!target || !observation || observation.kind !== 'present') return
|
|
167
|
+
if (typeof target.displayPath !== 'string' || !target.displayPath) return
|
|
168
|
+
queue(target.displayPath)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
ctx.effect(() => () => {
|
|
172
|
+
if (timer) clearTimeout(timer)
|
|
173
|
+
})
|
|
174
|
+
}
|
package/src/link.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function linkEntries(store) {
|
|
2
|
+
const all = store.allEntries()
|
|
3
|
+
const symbols = all.filter((e) => e.type === 'symbol')
|
|
4
|
+
const docs = all.filter((e) => e.type === 'doc')
|
|
5
|
+
if (!symbols.length || !docs.length) return 0
|
|
6
|
+
|
|
7
|
+
const symbolByName = new Map()
|
|
8
|
+
for (const s of symbols) {
|
|
9
|
+
const name = s.keywords[0]
|
|
10
|
+
if (!name || name.length < 3) continue
|
|
11
|
+
if (!symbolByName.has(name)) symbolByName.set(name, [])
|
|
12
|
+
symbolByName.get(name).push(s)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let links = 0
|
|
16
|
+
for (const doc of docs) {
|
|
17
|
+
const linked = new Set()
|
|
18
|
+
const haystack = `${doc.title || ''} ${doc.summary || ''} ${doc.keywords ? doc.keywords.join(' ') : ''}`.toLowerCase()
|
|
19
|
+
for (const [name, syms] of symbolByName) {
|
|
20
|
+
if (haystack.includes(name.toLowerCase())) {
|
|
21
|
+
for (const s of syms) {
|
|
22
|
+
const before = linked.size
|
|
23
|
+
linked.add(s.id)
|
|
24
|
+
if (linked.size > before) links++
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
doc.linkedSymbols = linked.size ? [...linked] : undefined
|
|
29
|
+
}
|
|
30
|
+
return links
|
|
31
|
+
}
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
import { tokenize } from './util/search.js'
|
|
3
|
+
|
|
4
|
+
function systemMessage(text) {
|
|
5
|
+
return { role: 'system', content: [{ type: 'text', text }] }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function textOf(message) {
|
|
9
|
+
const blocks = message.content || []
|
|
10
|
+
return blocks
|
|
11
|
+
.filter((b) => b.type === 'text' && typeof b.text === 'string')
|
|
12
|
+
.map((b) => b.text)
|
|
13
|
+
.join('\n')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_SUMMARY = 300
|
|
17
|
+
|
|
18
|
+
export function summarizeText(text, max = MAX_SUMMARY) {
|
|
19
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim()
|
|
20
|
+
if (!flat) return ''
|
|
21
|
+
if (flat.length <= max) return flat
|
|
22
|
+
const clip = max - 1
|
|
23
|
+
const clipped = flat.slice(0, clip)
|
|
24
|
+
const lastBreak = Math.max(clipped.lastIndexOf('。'), clipped.lastIndexOf('.'), clipped.lastIndexOf(';'))
|
|
25
|
+
return lastBreak > clip * 0.4 ? clipped.slice(0, lastBreak + 1) : clipped + '…'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function chatText(llm, system, user, { timeoutMs = 120000 } = {}) {
|
|
29
|
+
const assembler = new BlockAssembler()
|
|
30
|
+
const controller = new AbortController()
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
32
|
+
try {
|
|
33
|
+
for await (const chunk of llm.stream({
|
|
34
|
+
messages: [systemMessage(system), createUserMessage({ content: [{ type: 'text', text: user }] })],
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
})) {
|
|
37
|
+
assembler.push(chunk)
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer)
|
|
41
|
+
}
|
|
42
|
+
return textOf(assembler.message())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseStructuredJson(text) {
|
|
46
|
+
return parseJson(text, (parsed) => parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseJsonArray(text) {
|
|
50
|
+
return parseJson(text, (parsed) => Array.isArray(parsed))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseJson(text, validate) {
|
|
54
|
+
if (!text) return null
|
|
55
|
+
let candidate = text.trim()
|
|
56
|
+
const fence = candidate.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
|
57
|
+
if (fence) candidate = fence[1].trim()
|
|
58
|
+
const first = candidate.indexOf('[')
|
|
59
|
+
const firstObj = candidate.indexOf('{')
|
|
60
|
+
let start = firstObj
|
|
61
|
+
if (first >= 0 && (firstObj < 0 || first < firstObj)) start = first
|
|
62
|
+
const end = candidate.lastIndexOf(start === first ? ']' : '}')
|
|
63
|
+
if (start >= 0 && end > start) {
|
|
64
|
+
candidate = candidate.slice(start, end + 1)
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(candidate)
|
|
68
|
+
if (validate(parsed)) return parsed
|
|
69
|
+
} catch {
|
|
70
|
+
// fall through
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function expandQuery(llm, query, count = 6) {
|
|
76
|
+
if (!llm) return [query]
|
|
77
|
+
const system =
|
|
78
|
+
'You are a search-query expander for a codebase/document memory search engine. ' +
|
|
79
|
+
'Given a user query, return a STRICT JSON array of alternative search queries that ' +
|
|
80
|
+
'capture the same intent with different words: synonyms, English/Chinese equivalents, ' +
|
|
81
|
+
'code identifier guesses, and narrower/longer phrasings. Include the original query first. ' +
|
|
82
|
+
'Output only the JSON array of strings, no fences, no commentary.'
|
|
83
|
+
try {
|
|
84
|
+
const raw = await chatText(llm, system, `Query: "${query}"\n\nReturn the JSON array.`)
|
|
85
|
+
const parsed = parseJsonArray(raw)
|
|
86
|
+
if (Array.isArray(parsed) && parsed.length) {
|
|
87
|
+
const variants = parsed.map(String).filter((s) => s.trim()).slice(0, count)
|
|
88
|
+
if (variants.length) return variants
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// fall through to the raw query
|
|
92
|
+
}
|
|
93
|
+
return [query]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function extractDocEntry(llm, chunk, sourcePath) {
|
|
97
|
+
const system =
|
|
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). ' +
|
|
103
|
+
'Do not include markdown fences, do not add commentary, output only the JSON object.'
|
|
104
|
+
|
|
105
|
+
const user =
|
|
106
|
+
`Document: ${sourcePath}\nSection: ${chunk.title || '(untitled)'}\n\n` +
|
|
107
|
+
`Content:\n${chunk.text.slice(0, 6000)}\n\nReturn the JSON object.`
|
|
108
|
+
|
|
109
|
+
const fallback = () => ({
|
|
110
|
+
title: chunk.title || sourcePath,
|
|
111
|
+
summary: summarizeText(chunk.text),
|
|
112
|
+
keywords: tokenize(chunk.title).slice(0, 5),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (!llm) return fallback()
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const raw = await chatText(llm, system, user)
|
|
119
|
+
const parsed = parseStructuredJson(raw)
|
|
120
|
+
if (!parsed || typeof parsed.summary !== 'string' || !parsed.summary.trim()) return fallback()
|
|
121
|
+
const kw = Array.isArray(parsed.keywords) ? parsed.keywords.map(String).filter((k) => k).slice(0, 8) : []
|
|
122
|
+
return {
|
|
123
|
+
title: typeof parsed.title === 'string' && parsed.title.trim() ? parsed.title.trim() : chunk.title || sourcePath,
|
|
124
|
+
summary: summarizeText(parsed.summary),
|
|
125
|
+
keywords: kw.length ? kw : tokenize(chunk.title).slice(0, 5),
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
return fallback()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { getDocument, GlobalWorkerOptions, OPS } from 'pdfjs-dist/legacy/build/pdf.mjs'
|
|
3
|
+
|
|
4
|
+
const PDFJS_OPTIONS = {
|
|
5
|
+
useSystemFonts: true,
|
|
6
|
+
isEvalSupported: false,
|
|
7
|
+
useWorkerFetch: false,
|
|
8
|
+
useWorker: false,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildMarkdown(pages) {
|
|
12
|
+
return pages
|
|
13
|
+
.map((p) => (pages.length > 1 ? `## Page ${p.page}\n\n${p.text}` : p.text))
|
|
14
|
+
.join('\n\n')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractPageText(items) {
|
|
18
|
+
const lines = []
|
|
19
|
+
let currentY = null
|
|
20
|
+
let currentLine = ''
|
|
21
|
+
for (const item of items) {
|
|
22
|
+
if (!item.str || !item.str.trim()) continue
|
|
23
|
+
const y = item.transform ? item.transform[5] : 0
|
|
24
|
+
if (currentY === null || Math.abs(y - currentY) < 2) {
|
|
25
|
+
currentY = y
|
|
26
|
+
currentLine += (currentLine && !currentLine.endsWith(' ') ? ' ' : '') + item.str
|
|
27
|
+
} else {
|
|
28
|
+
lines.push(currentLine)
|
|
29
|
+
currentY = y
|
|
30
|
+
currentLine = item.str
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (currentLine) lines.push(currentLine)
|
|
34
|
+
return lines.join('\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function parsePdf(filePath, { pages = null, maxPages = 1000, backend = 'pdfjs', collectLayoutStats = false } = {}) {
|
|
38
|
+
const data = new Uint8Array(await readFile(filePath))
|
|
39
|
+
const loadingTask = getDocument({ data, ...PDFJS_OPTIONS })
|
|
40
|
+
const doc = await loadingTask.promise
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const total = doc.numPages
|
|
44
|
+
if (total > maxPages) {
|
|
45
|
+
throw new Error(`PDF has ${total} pages, over the maxPages limit of ${maxPages}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pageList = []
|
|
49
|
+
let imageCount = 0
|
|
50
|
+
let sampledPages = 0
|
|
51
|
+
for (let n = 1; n <= total; n++) {
|
|
52
|
+
if (pages && !pages.has(n)) continue
|
|
53
|
+
const page = await doc.getPage(n)
|
|
54
|
+
const content = await page.getTextContent()
|
|
55
|
+
pageList.push({ page: n, text: extractPageText(content.items) })
|
|
56
|
+
|
|
57
|
+
if (collectLayoutStats && sampledPages < 5) {
|
|
58
|
+
const opList = await page.getOperatorList()
|
|
59
|
+
for (const fn of opList.fnArray) {
|
|
60
|
+
if (fn === OPS.paintImageXObject || fn === OPS.paintInlineImageXObject) imageCount++
|
|
61
|
+
}
|
|
62
|
+
sampledPages++
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (pageList.length === 0) {
|
|
66
|
+
throw new Error('No pages matched the selection, or the PDF contains no extractable text')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const totalTextChars = pageList.reduce((sum, p) => sum + p.text.length, 0)
|
|
70
|
+
return {
|
|
71
|
+
pageCount: pageList.length,
|
|
72
|
+
pages: pageList,
|
|
73
|
+
markdown: buildMarkdown(pageList),
|
|
74
|
+
backend,
|
|
75
|
+
stats: collectLayoutStats
|
|
76
|
+
? {
|
|
77
|
+
totalTextChars,
|
|
78
|
+
avgImagesPerPage: sampledPages ? imageCount / sampledPages : 0,
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
await loadingTask.destroy()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function parsePdfInfo(filePath, maxPages = 1000) {
|
|
88
|
+
const data = new Uint8Array(await readFile(filePath))
|
|
89
|
+
const loadingTask = getDocument({ data, ...PDFJS_OPTIONS })
|
|
90
|
+
const doc = await loadingTask.promise
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (doc.numPages > maxPages) {
|
|
94
|
+
throw new Error(`PDF has ${doc.numPages} pages, over the maxPages limit of ${maxPages}`)
|
|
95
|
+
}
|
|
96
|
+
let meta = {}
|
|
97
|
+
try {
|
|
98
|
+
meta = await doc.getMetadata()
|
|
99
|
+
} catch {
|
|
100
|
+
// metadata is optional
|
|
101
|
+
}
|
|
102
|
+
const info = meta.info || {}
|
|
103
|
+
return {
|
|
104
|
+
pageCount: doc.numPages,
|
|
105
|
+
title: info.Title ?? null,
|
|
106
|
+
author: info.Author ?? null,
|
|
107
|
+
subject: info.Subject ?? null,
|
|
108
|
+
created: info.CreationDate ?? null,
|
|
109
|
+
modified: info.ModDate ?? null,
|
|
110
|
+
encrypted: doc.isEncrypted,
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
await loadingTask.destroy()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function configurePdfjsWorker(workerSrc) {
|
|
118
|
+
GlobalWorkerOptions.workerSrc = workerSrc
|
|
119
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { rankEntries, rankExperience, tokenize } from './util/search.js'
|
|
5
|
+
|
|
6
|
+
const INDEX_FILE = 'index.json'
|
|
7
|
+
const ENTRIES_FILE = 'entries.json'
|
|
8
|
+
const EXPERIENCE_FILE = 'experience.json'
|
|
9
|
+
const WATCH_FILE = 'watch.json'
|
|
10
|
+
|
|
11
|
+
const dirLocks = new Map()
|
|
12
|
+
|
|
13
|
+
export async function withStoreLock(memoryDir, fn) {
|
|
14
|
+
const key = path.resolve(memoryDir)
|
|
15
|
+
const prev = dirLocks.get(key) || Promise.resolve()
|
|
16
|
+
let release
|
|
17
|
+
const cur = new Promise((resolve) => {
|
|
18
|
+
release = resolve
|
|
19
|
+
})
|
|
20
|
+
const chain = prev.then(() => cur)
|
|
21
|
+
dirLocks.set(key, chain)
|
|
22
|
+
await prev
|
|
23
|
+
try {
|
|
24
|
+
return await fn()
|
|
25
|
+
} finally {
|
|
26
|
+
release()
|
|
27
|
+
if (dirLocks.get(key) === chain) dirLocks.delete(key)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadJson(filePath, fallback) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
34
|
+
} catch {
|
|
35
|
+
return fallback
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeJsonAtomic(filePath, data) {
|
|
40
|
+
const tmp = `${filePath}.${process.pid}.tmp`
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(tmp, JSON.stringify(data))
|
|
43
|
+
renameSync(tmp, filePath)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(tmp)
|
|
47
|
+
} catch {
|
|
48
|
+
// tmp already gone (rename succeeded) or undeletable; nothing to do
|
|
49
|
+
}
|
|
50
|
+
throw err
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ProjectMemoryStore {
|
|
55
|
+
constructor(memoryDir) {
|
|
56
|
+
this.dir = memoryDir
|
|
57
|
+
this.files = {}
|
|
58
|
+
this.entries = {}
|
|
59
|
+
this.experience = []
|
|
60
|
+
this.watchlist = []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
load() {
|
|
64
|
+
const index = loadJson(path.join(this.dir, INDEX_FILE), {})
|
|
65
|
+
this.files = index.files || {}
|
|
66
|
+
this.entries = loadJson(path.join(this.dir, ENTRIES_FILE), {})
|
|
67
|
+
this.experience = loadJson(path.join(this.dir, EXPERIENCE_FILE), [])
|
|
68
|
+
this.watchlist = loadJson(path.join(this.dir, WATCH_FILE), [])
|
|
69
|
+
return this
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cleanStaleTmp() {
|
|
73
|
+
let entries
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(this.dir)
|
|
76
|
+
} catch {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
const now = Date.now()
|
|
80
|
+
for (const name of entries) {
|
|
81
|
+
if (!name.endsWith('.tmp')) continue
|
|
82
|
+
try {
|
|
83
|
+
if (now - statSync(path.join(this.dir, name)).mtimeMs > 60000) unlinkSync(path.join(this.dir, name))
|
|
84
|
+
} catch {
|
|
85
|
+
// already gone or locked; skip
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
save() {
|
|
91
|
+
mkdirSync(this.dir, { recursive: true })
|
|
92
|
+
this.cleanStaleTmp()
|
|
93
|
+
writeJsonAtomic(path.join(this.dir, INDEX_FILE), { version: 1, files: this.files })
|
|
94
|
+
writeJsonAtomic(path.join(this.dir, ENTRIES_FILE), this.entries)
|
|
95
|
+
writeJsonAtomic(path.join(this.dir, EXPERIENCE_FILE), this.experience)
|
|
96
|
+
writeJsonAtomic(path.join(this.dir, WATCH_FILE), this.watchlist)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
addWatch(root) {
|
|
100
|
+
if (!this.watchlist.includes(root)) {
|
|
101
|
+
this.watchlist.push(root)
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fileRecord(relPath) {
|
|
108
|
+
return this.files[relPath]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
markFile(relPath, record) {
|
|
112
|
+
this.files[relPath] = record
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setEntries(relPath, entries) {
|
|
116
|
+
if (entries.length) {
|
|
117
|
+
this.entries[relPath] = entries
|
|
118
|
+
} else {
|
|
119
|
+
delete this.entries[relPath]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
removeFile(relPath) {
|
|
124
|
+
delete this.files[relPath]
|
|
125
|
+
delete this.entries[relPath]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
allEntries() {
|
|
129
|
+
const out = []
|
|
130
|
+
for (const list of Object.values(this.entries)) {
|
|
131
|
+
for (const entry of list) out.push(entry)
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
searchEntries(query, limit = 8) {
|
|
137
|
+
return rankEntries(this.allEntries(), query, limit)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
addExperience({ problem, solution, sourceFile }) {
|
|
141
|
+
const existing = this.findSupersede(problem)
|
|
142
|
+
const now = new Date().toISOString()
|
|
143
|
+
if (existing) {
|
|
144
|
+
existing.problem = problem
|
|
145
|
+
existing.solution = solution
|
|
146
|
+
if (sourceFile) existing.sourceFile = sourceFile
|
|
147
|
+
existing.updatedAt = now
|
|
148
|
+
return { id: existing.id, superseded: true }
|
|
149
|
+
}
|
|
150
|
+
const id = randomUUID()
|
|
151
|
+
this.experience.push({ id, problem, solution, sourceFile, createdAt: now, updatedAt: now })
|
|
152
|
+
this.pruneExperience()
|
|
153
|
+
return { id, superseded: false }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pruneExperience() {
|
|
157
|
+
const indexedFileCount = Object.keys(this.files).length
|
|
158
|
+
const max = Math.max(100, Math.min(2000, indexedFileCount * 2))
|
|
159
|
+
if (this.experience.length <= max) return 0
|
|
160
|
+
const sorted = [...this.experience].sort((a, b) => (a.updatedAt < b.updatedAt ? -1 : 1))
|
|
161
|
+
const victims = new Set(sorted.slice(0, this.experience.length - max).map((e) => e.id))
|
|
162
|
+
this.experience = this.experience.filter((e) => !victims.has(e.id))
|
|
163
|
+
return victims.size
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
findSupersede(problem) {
|
|
167
|
+
const tokens = tokenize(problem)
|
|
168
|
+
if (!tokens.length) return null
|
|
169
|
+
let best = null
|
|
170
|
+
let bestOverlap = 0
|
|
171
|
+
for (const item of this.experience) {
|
|
172
|
+
const itemTokens = tokenize(item.problem)
|
|
173
|
+
const overlap = itemTokens.filter((t) => tokens.includes(t)).length
|
|
174
|
+
const base = Math.min(tokens.length, itemTokens.length)
|
|
175
|
+
if (base && overlap / base >= 0.6 && overlap > bestOverlap) {
|
|
176
|
+
best = item
|
|
177
|
+
bestOverlap = overlap
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return best
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
removeExperience(idOrQuery) {
|
|
184
|
+
const before = this.experience.length
|
|
185
|
+
if (idOrQuery && this.experience.some((item) => item.id === idOrQuery)) {
|
|
186
|
+
this.experience = this.experience.filter((item) => item.id !== idOrQuery)
|
|
187
|
+
} else {
|
|
188
|
+
const tokens = tokenize(idOrQuery)
|
|
189
|
+
if (!tokens.length) return 0
|
|
190
|
+
this.experience = this.experience.filter((item) => {
|
|
191
|
+
const itemTokens = tokenize(`${item.problem} ${item.solution}`)
|
|
192
|
+
if (!itemTokens.length) return true
|
|
193
|
+
const overlap = itemTokens.filter((t) => tokens.includes(t)).length
|
|
194
|
+
return overlap / Math.min(tokens.length, itemTokens.length) < 0.5
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
return before - this.experience.length
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
stats() {
|
|
201
|
+
return {
|
|
202
|
+
files: Object.keys(this.files).length,
|
|
203
|
+
entries: this.allEntries().length,
|
|
204
|
+
experience: this.experience.length,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|