@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/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
+ }