@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/symbols.js ADDED
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ const JS_LIKE = new Set(['.js', '.mjs', '.cjs', '.ts', '.jsx', '.tsx'])
4
+ const PYTHON = new Set(['.py'])
5
+ const GO = new Set(['.go'])
6
+ const RUST = new Set(['.rs'])
7
+ const C_FAMILY = new Set(['.c', '.cpp', '.cc', '.h', '.hpp', '.cs', '.java'])
8
+ const SHELL = new Set(['.sh', '.zsh'])
9
+
10
+ const CONTROL = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'foreach', 'using', 'lock', 'var', 'function'])
11
+
12
+ function matchJsLike(line) {
13
+ let m = line.match(/^export\s+(?:default\s+)?(?:async\s+)?(?:function\s+([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*))/)
14
+ if (m) return { name: m[1] || m[2], kind: m[1] ? 'function' : 'class' }
15
+ m = line.match(/^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/)
16
+ if (m) return { name: m[1], kind: 'function' }
17
+ m = line.match(/^(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/)
18
+ if (m) return { name: m[1], kind: 'class' }
19
+ m = line.match(/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(/)
20
+ if (m) return { name: m[1], kind: 'function' }
21
+ m = line.match(/^(?:export\s+)?(?:async\s+)?function\s*\(/) // anonymous
22
+ if (m) return { name: '(anonymous)', kind: 'function' }
23
+ return null
24
+ }
25
+
26
+ function matchPython(line) {
27
+ let m = line.match(/^class\s+(\w+)\s*(?:\(|:)/)
28
+ if (m) return { name: m[1], kind: 'class' }
29
+ m = line.match(/^def\s+(\w+)\s*\(/)
30
+ if (m) return { name: m[1], kind: 'function' }
31
+ m = line.match(/^async\s+def\s+(\w+)\s*\(/)
32
+ if (m) return { name: m[1], kind: 'function' }
33
+ return null
34
+ }
35
+
36
+ function matchGo(line) {
37
+ let m = line.match(/^func\s+\([^)]*\)\s+(\w+)\s*\(/)
38
+ if (m) return { name: m[1], kind: 'method' }
39
+ m = line.match(/^func\s+(\w+)\s*\(/)
40
+ if (m) return { name: m[1], kind: 'function' }
41
+ m = line.match(/^type\s+(\w+)\s+(?:struct|interface)\b/)
42
+ if (m) return { name: m[1], kind: 'type' }
43
+ return null
44
+ }
45
+
46
+ function matchRust(line) {
47
+ let m = line.match(/^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)\s*\(/)
48
+ if (m) return { name: m[1], kind: 'function' }
49
+ m = line.match(/^(?:pub\s+)?(?:struct|enum|trait|impl)\s+(\w+)/)
50
+ if (m) return { name: m[1], kind: line.includes('impl') ? 'impl' : 'type' }
51
+ return null
52
+ }
53
+
54
+ function matchCFamily(line) {
55
+ const type = line.match(/^(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*\b(?:class|interface|struct|enum|record)\s+(\w+)/)
56
+ if (type) return { name: type[1], kind: 'class' }
57
+ const fn = line.match(
58
+ /^(?:public|private|protected|internal|static|abstract|virtual|override|sealed|async|unsafe|extern|readonly|ref|partial|\s)*(\b[A-Za-z_]\w*(?:<[^<>]*>)?(?:\s*\[\s*\])?|\([^(){};]*\))\s+([A-Za-z_]\w*)\s*\([^(){};]*\)\s*(?:where\s+[^{;]*?)?\s*(?:;|\{)?\s*$/,
59
+ )
60
+ if (fn && !CONTROL.has(fn[1])) return { name: fn[2], kind: 'function' }
61
+ return null
62
+ }
63
+
64
+ function matchShell(line) {
65
+ const m = line.match(/^([A-Za-z_]\w*)\s*\(\s*\)/)
66
+ return m ? { name: m[1], kind: 'function' } : null
67
+ }
68
+
69
+ export function scanSymbols(filePath, content) {
70
+ const ext = filePath.slice(filePath.lastIndexOf('.'))
71
+ const symbols = []
72
+ const lines = content.split(/\r?\n/)
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const raw = lines[i]
75
+ const line = raw.replace(/\/\/.*$/, '').replace(/#.*$/, '').trim()
76
+ if (!line) continue
77
+ let matched = null
78
+ if (JS_LIKE.has(ext)) matched = matchJsLike(line)
79
+ else if (PYTHON.has(ext)) matched = matchPython(line)
80
+ else if (GO.has(ext)) matched = matchGo(line)
81
+ else if (RUST.has(ext)) matched = matchRust(line)
82
+ else if (C_FAMILY.has(ext)) matched = matchCFamily(line)
83
+ else if (SHELL.has(ext)) matched = matchShell(line)
84
+ else if (/^(?:def|func|fn|function)\s+(\w+)/.test(line)) {
85
+ matched = { name: line.match(/^(?:def|func|fn|function)\s+(\w+)/)[1], kind: 'function' }
86
+ }
87
+ if (matched) {
88
+ symbols.push({
89
+ id: `${String(filePath).replace(/[\\/:\s]/g, '_')}#${i + 1}`,
90
+ sourcePath: filePath,
91
+ sourceLine: i + 1,
92
+ type: 'symbol',
93
+ title: `${matched.name} (${matched.kind})`,
94
+ summary: `${matched.kind} "${matched.name}" declared at ${filePath}:${i + 1}`,
95
+ keywords: [matched.name, matched.kind],
96
+ text: raw.trim().slice(0, 200),
97
+ })
98
+ }
99
+ }
100
+ return symbols
101
+ }
@@ -0,0 +1,36 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
+ import { ProjectMemoryStore, withStoreLock } from '../store.js'
4
+
5
+ export function forgetTool(config) {
6
+ return defineTool({
7
+ name: 'forget',
8
+ description:
9
+ 'Delete experience notes from project memory by id or by matching keywords. Use to clean stale/obsolete notes.',
10
+ parameters: {
11
+ id_or_query: {
12
+ type: 'string',
13
+ required: true,
14
+ description: 'Experience note id (from remember/search output), or keywords to match.',
15
+ },
16
+ root: {
17
+ type: 'string',
18
+ description: 'Project root of the memory store. Defaults to the current working directory.',
19
+ },
20
+ },
21
+ output: {
22
+ schema: { type: 'string' },
23
+ render: (_args, value) => [{ type: 'text', text: value }],
24
+ },
25
+ async execute(args, exec) {
26
+ const root = resolveIndexRoot(exec, args.root)
27
+ const memoryDir = memoryRootFor(root, config.memoryDir)
28
+ return withStoreLock(memoryDir, () => {
29
+ const store = new ProjectMemoryStore(memoryDir).load()
30
+ const removed = store.removeExperience(args.id_or_query)
31
+ store.save()
32
+ return removed > 0 ? `Removed ${removed} experience note(s).` : 'No matching experience note found.'
33
+ })
34
+ },
35
+ })
36
+ }
@@ -0,0 +1,70 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import path from 'node:path'
3
+ import { assertReadableFile, memoryRootFor, sha256OfFile, storeKey } from '../util/fs.js'
4
+ import { buildDocEntries } from '../doc-pipeline.js'
5
+ import { linkEntries } from '../link.js'
6
+ import { ProjectMemoryStore, withStoreLock } from '../store.js'
7
+ import { findProjectRoot } from '../lazy.js'
8
+
9
+ export function indexDocTool(ctx, config) {
10
+ return defineTool({
11
+ name: 'index_doc',
12
+ description:
13
+ 'Index a project document (PDF, Markdown, txt) into persistent project memory: split into sections, ' +
14
+ 'summarize each with the LLM, and store cited summaries (path + line) for later query_memory recall. ' +
15
+ 'Re-indexing the same unchanged file is a no-op (content-hash skip).',
16
+ parameters: {
17
+ file_path: {
18
+ type: 'string',
19
+ required: true,
20
+ description: 'Absolute path to the document to index.',
21
+ },
22
+ root: {
23
+ type: 'string',
24
+ description: 'Project root where the .dsh-project-memory store lives. Defaults to the file\'s directory.',
25
+ },
26
+ },
27
+ output: {
28
+ schema: { type: 'string' },
29
+ render: (_args, value) => [{ type: 'text', text: value }],
30
+ },
31
+ async execute(args) {
32
+ const filePath = assertReadableFile(args.file_path, config.maxFileSizeMb)
33
+ const root = path.resolve(args.root && args.root.trim() ? args.root : findProjectRoot(filePath))
34
+ const memoryDir = memoryRootFor(root, config.memoryDir)
35
+
36
+ return withStoreLock(memoryDir, async () => {
37
+ const store = new ProjectMemoryStore(memoryDir).load()
38
+
39
+ const rel = storeKey(path.relative(root, filePath).split(path.sep).join('/'))
40
+ const { hash, size } = await sha256OfFile(filePath)
41
+ const existing = store.fileRecord(rel)
42
+ if (existing && existing.sha256 === hash) {
43
+ return `Skipped (unchanged): ${rel}\nAlready indexed with ${(store.entries[rel] || []).length} entry/entries.`
44
+ }
45
+
46
+ const entries = await buildDocEntries(ctx.llm, filePath, {
47
+ chunkChars: config.chunkChars,
48
+ maxChunks: config.maxChunksPerFile,
49
+ maxFileSizeMb: config.maxFileSizeMb,
50
+ maxPdfPages: config.maxPdfPages,
51
+ })
52
+ if (entries === null) {
53
+ store.removeFile(rel)
54
+ store.save()
55
+ return `Skipped: ${rel} looks like a reflection dump, not a document.`
56
+ }
57
+ store.setEntries(rel, entries)
58
+ store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
59
+ store.save()
60
+ const links = linkEntries(store)
61
+ if (links) store.save()
62
+
63
+ const preview = entries
64
+ .map((e) => ` - ${e.title} @ ${rel}:${e.sourceLine}`)
65
+ .join('\n')
66
+ return `Indexed: ${rel}\nEntries: ${entries.length}\n${preview}`
67
+ })
68
+ },
69
+ })
70
+ }
@@ -0,0 +1,127 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import path from 'node:path'
3
+ import { readFileSync, statSync } from 'node:fs'
4
+ import { isSupportedCode, isSupportedDoc, looksLikeDump, memoryRootFor, relativePath, sha256OfFile, storeKey, walkDir } 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
+ export async function indexRepository(ctx, config, root, { reindex = false } = {}) {
11
+ return withStoreLock(memoryRootFor(root, config.memoryDir), async () => {
12
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
13
+
14
+ const files = walkDir(root)
15
+ const seen = new Set()
16
+ let indexed = 0
17
+ let updated = 0
18
+ let skipped = 0
19
+ let removed = 0
20
+ const failures = []
21
+
22
+ for (const filePath of files) {
23
+ const rel = storeKey(relativePath(root, filePath))
24
+ seen.add(rel)
25
+ const ext = path.extname(filePath).toLowerCase()
26
+ if (!isSupportedDoc(ext) && !isSupportedCode(ext)) continue
27
+
28
+ try {
29
+ const size = statSync(filePath).size
30
+ if (isSupportedCode(ext) && config.maxFileSizeMb && size > config.maxFileSizeMb * 1024 * 1024) {
31
+ store.removeFile(rel)
32
+ skipped++
33
+ continue
34
+ }
35
+
36
+ const existing = store.fileRecord(rel)
37
+ const { hash } = await sha256OfFile(filePath)
38
+ if (!reindex && existing && existing.sha256 === hash) {
39
+ skipped++
40
+ continue
41
+ }
42
+
43
+ let entries
44
+ if (isSupportedCode(ext)) {
45
+ const content = readFileSync(filePath, 'utf8')
46
+ entries = scanSymbols(filePath, content)
47
+ store.markFile(rel, { sha256: hash, size, type: 'code', indexedAt: new Date().toISOString() })
48
+ updated++
49
+ } else {
50
+ const content = readFileSync(filePath, 'utf8')
51
+ if (looksLikeDump(content)) {
52
+ store.removeFile(rel)
53
+ skipped++
54
+ continue
55
+ }
56
+ entries = await buildDocEntries(ctx.llm, filePath, {
57
+ chunkChars: config.chunkChars,
58
+ maxChunks: config.maxChunksPerFile,
59
+ maxFileSizeMb: config.maxFileSizeMb,
60
+ maxPdfPages: config.maxPdfPages,
61
+ })
62
+ if (entries === null) {
63
+ store.removeFile(rel)
64
+ skipped++
65
+ continue
66
+ }
67
+ store.markFile(rel, { sha256: hash, size, type: 'doc', indexedAt: new Date().toISOString() })
68
+ indexed++
69
+ }
70
+ store.setEntries(rel, entries)
71
+ } catch (err) {
72
+ store.removeFile(rel)
73
+ failures.push(rel)
74
+ }
75
+ }
76
+
77
+ for (const rel of Object.keys(store.files)) {
78
+ if (!seen.has(rel)) {
79
+ store.removeFile(rel)
80
+ removed++
81
+ }
82
+ }
83
+
84
+ store.save()
85
+ const links = linkEntries(store)
86
+ if (links) store.save()
87
+ const stats = store.stats()
88
+ let report =
89
+ `Indexed project: ${root}\n` +
90
+ `docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
91
+ `memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes` +
92
+ (links ? `, ${links} doc<->symbol links` : '')
93
+ if (failures.length) {
94
+ report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
95
+ }
96
+ return report
97
+ })
98
+ }
99
+
100
+ export function indexRepoTool(ctx, config) {
101
+ return defineTool({
102
+ name: 'index_repo',
103
+ description:
104
+ 'Index a whole project into persistent memory. Documents (PDF/Markdown/txt) are summarized by the LLM; ' +
105
+ 'code files get a zero-token symbol table (function/class names with line numbers). Incremental: only changed ' +
106
+ 'files are re-extracted (content-hash), deleted files are removed from memory. Call once per project, then query_memory.',
107
+ parameters: {
108
+ root: {
109
+ type: 'string',
110
+ required: true,
111
+ description: 'Absolute path to the project root to index.',
112
+ },
113
+ reindex: {
114
+ type: 'boolean',
115
+ description: 'Force full re-index, ignoring content-hash skips. Default false.',
116
+ },
117
+ },
118
+ output: {
119
+ schema: { type: 'string' },
120
+ render: (_args, value) => [{ type: 'text', text: value }],
121
+ },
122
+ async execute(args) {
123
+ const root = path.resolve(args.root)
124
+ return indexRepository(ctx, config, root, { reindex: Boolean(args.reindex) })
125
+ },
126
+ })
127
+ }
@@ -0,0 +1,95 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
+ import { ProjectMemoryStore } from '../store.js'
4
+ import { expandQuery } from '../llm.js'
5
+ import { rankEntriesMergedScored, rankExperienceScored } from '../util/search.js'
6
+ import { truncate } from '../util/text.js'
7
+
8
+ export function queryMemoryTool(ctx, config) {
9
+ return defineTool({
10
+ name: 'query_memory',
11
+ description:
12
+ 'Search persistent project memory: doc summaries and code symbol tables (indexed via index_doc/index_repo) ' +
13
+ 'plus experience notes (problem -> solution, saved via remember). Every hit returns its source path and line so ' +
14
+ 'you can verify by reading the real file. Docs are cross-linked to the code symbols they mention. ' +
15
+ 'Use BEFORE grepping when you need orientation, a spec constraint, or a past decision.',
16
+ parameters: {
17
+ query: {
18
+ type: 'string',
19
+ required: true,
20
+ description: 'What to look for, e.g. "payment module fees", "who handles refunds", "spec constraint on timeouts".',
21
+ },
22
+ root: {
23
+ type: 'string',
24
+ description: 'Project root of the memory store to search. Defaults to the current working directory.',
25
+ },
26
+ type: {
27
+ type: 'string',
28
+ enum: ['all', 'doc', 'symbol', 'experience'],
29
+ description: 'Which memory layer to search. Default "all".',
30
+ },
31
+ limit: {
32
+ type: 'number',
33
+ description: 'Max results to return. Default 8.',
34
+ },
35
+ },
36
+ output: {
37
+ schema: { type: 'string' },
38
+ render: (_args, value) => [{ type: 'text', text: value }],
39
+ },
40
+ async execute(args, exec) {
41
+ const root = resolveIndexRoot(exec, args.root)
42
+ const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
43
+ const type = args.type || 'all'
44
+ const limit = Math.max(1, Math.min(Number(args.limit) || 8, 20))
45
+
46
+ const queries = config.llmQueryExpansion
47
+ ? await expandQuery(ctx.llm, args.query, config.expansionCount)
48
+ : [args.query]
49
+ const symbolById = new Map()
50
+ for (const e of store.allEntries()) {
51
+ if (e.type === 'symbol') symbolById.set(e.id, e)
52
+ }
53
+
54
+ const lines = []
55
+ if (type === 'all' || type === 'doc' || type === 'symbol') {
56
+ const pool = type === 'all' ? store.allEntries() : store.allEntries().filter((e) => e.type === type)
57
+ const scored = rankEntriesMergedScored(pool, queries, limit)
58
+ if (scored.length) {
59
+ const top = scored[0].score || 1
60
+ lines.push(`## Memory (${type === 'all' ? 'docs + symbols' : type})`)
61
+ for (const { entry: e, score } of scored) {
62
+ const source = e.sourceLine ? `${e.sourcePath}:${e.sourceLine}` : e.sourcePath
63
+ const rel = Math.round((score / top) * 100)
64
+ lines.push(`### ${e.title} (score: ${rel})\n- source: ${source}\n- ${e.summary}`)
65
+ if (e.type === 'doc' && Array.isArray(e.linkedSymbols) && e.linkedSymbols.length) {
66
+ const refs = e.linkedSymbols.slice(0, 5).map((id) => {
67
+ const s = symbolById.get(id)
68
+ return s ? `${s.title} @ ${s.sourcePath}:${s.sourceLine}` : id
69
+ })
70
+ lines.push(`- references: ${refs.join('; ')}`)
71
+ }
72
+ }
73
+ }
74
+ }
75
+ if (type === 'all' || type === 'experience') {
76
+ const scoredExp = rankExperienceScored(store.experience, queries, limit)
77
+ if (scoredExp.length) {
78
+ const expTop = scoredExp[0].score || 1
79
+ lines.push(`## Experience (past problems -> solutions)`)
80
+ for (const { item: e, score } of scoredExp) {
81
+ const source = e.sourceFile ? ` (source: ${e.sourceFile})` : ''
82
+ lines.push(
83
+ `### Problem: ${e.problem} (score: ${Math.round((score / expTop) * 100)}, id: ${e.id})\n- solution: ${e.solution}${source}\n- updated: ${e.updatedAt}`,
84
+ )
85
+ }
86
+ }
87
+ }
88
+
89
+ if (!lines.length) {
90
+ return `No memory matches for "${args.query}" in ${root}. Index it first with index_repo / index_doc, or note a fix with remember.`
91
+ }
92
+ return truncate(lines.join('\n\n'), config.maxOutputChars)
93
+ },
94
+ })
95
+ }
@@ -0,0 +1,53 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
+ import { ProjectMemoryStore, withStoreLock } from '../store.js'
4
+
5
+ export function rememberTool(config) {
6
+ return defineTool({
7
+ name: 'remember',
8
+ description:
9
+ 'Save an experience note (problem -> solution) into project memory, e.g. a bug you just fixed or a decision made. ' +
10
+ 'Retrieved later only when search_experience / query_memory matches the problem, never auto-injected. ' +
11
+ 'If a note with a similar problem exists, it is superseded instead of duplicated.',
12
+ parameters: {
13
+ problem: {
14
+ type: 'string',
15
+ required: true,
16
+ description: 'The problem/situation, e.g. "pdfjs OPS import fails on Node 24".',
17
+ },
18
+ solution: {
19
+ type: 'string',
20
+ required: true,
21
+ description: 'The concrete fix or decision, e.g. "import OPS from pdfjs-dist/legacy/build/pdf.mjs".',
22
+ },
23
+ root: {
24
+ type: 'string',
25
+ description: 'Project root of the memory store. Defaults to the current working directory.',
26
+ },
27
+ source_file: {
28
+ type: 'string',
29
+ description: 'Optional file the problem is about, for traceability.',
30
+ },
31
+ },
32
+ output: {
33
+ schema: { type: 'string' },
34
+ render: (_args, value) => [{ type: 'text', text: value }],
35
+ },
36
+ async execute(args, exec) {
37
+ const root = resolveIndexRoot(exec, args.root)
38
+ const memoryDir = memoryRootFor(root, config.memoryDir)
39
+ return withStoreLock(memoryDir, () => {
40
+ const store = new ProjectMemoryStore(memoryDir).load()
41
+ const result = store.addExperience({
42
+ problem: args.problem,
43
+ solution: args.solution,
44
+ sourceFile: args.source_file,
45
+ })
46
+ store.save()
47
+ return result.superseded
48
+ ? `Updated existing experience note (${result.id}).`
49
+ : `Saved experience note (${result.id}).`
50
+ })
51
+ },
52
+ })
53
+ }
@@ -0,0 +1,47 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import path from 'node:path'
3
+ import { memoryRootFor } from '../util/fs.js'
4
+ import { ProjectMemoryStore, withStoreLock } from '../store.js'
5
+
6
+ export function watchRepoTool(watchManager, config) {
7
+ return defineTool({
8
+ name: 'watch_repo',
9
+ description:
10
+ 'Start silent auto-refresh for a project: the plugin polls the root (configurable interval), ' +
11
+ 'detects changed/new docs and code files via mtime+content-hash, and re-indexes only those silently. ' +
12
+ 'No GUI, no manual re-index needed after this. Stop by reloading the plugin or calling with watch=false.',
13
+ parameters: {
14
+ root: {
15
+ type: 'string',
16
+ required: true,
17
+ description: 'Absolute path to the project root to watch.',
18
+ },
19
+ watch: {
20
+ type: 'boolean',
21
+ description: 'Set false to stop watching this root. Default true.',
22
+ },
23
+ },
24
+ output: {
25
+ schema: { type: 'string' },
26
+ render: (_args, value) => [{ type: 'text', text: value }],
27
+ },
28
+ async execute(args) {
29
+ const root = path.resolve(args.root)
30
+ const memoryDir = memoryRootFor(root, config.memoryDir)
31
+ return withStoreLock(memoryDir, () => {
32
+ const store = new ProjectMemoryStore(memoryDir).load()
33
+ if (args.watch === false) {
34
+ watchManager.removeRoot(root)
35
+ store.watchlist = store.watchlist.filter((r) => r !== root)
36
+ store.save()
37
+ return `Stopped watching: ${root}`
38
+ }
39
+ store.addWatch(root)
40
+ store.save()
41
+ watchManager.addRoot(root)
42
+ watchManager.start(config.watchInterval * 1000)
43
+ return `Watching ${root} (interval ${config.watchInterval}s). Docs/code changes will be re-indexed silently.`
44
+ })
45
+ },
46
+ })
47
+ }
package/src/util/fs.js ADDED
@@ -0,0 +1,113 @@
1
+ import { accessSync, constants, readdirSync, statSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
4
+ import path from 'node:path'
5
+
6
+ export function assertReadableFile(filePath, maxFileSizeMb) {
7
+ if (typeof filePath !== 'string' || !filePath) {
8
+ throw new Error('file_path must be a non-empty string')
9
+ }
10
+ let stats
11
+ try {
12
+ accessSync(filePath, constants.R_OK)
13
+ stats = statSync(filePath)
14
+ } catch {
15
+ throw new Error(`File not readable: ${filePath}`)
16
+ }
17
+ if (!stats.isFile()) {
18
+ throw new Error(`Not a regular file: ${filePath}`)
19
+ }
20
+ if (maxFileSizeMb && stats.size > maxFileSizeMb * 1024 * 1024) {
21
+ throw new Error(`File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB), limit is ${maxFileSizeMb} MB`)
22
+ }
23
+ return filePath
24
+ }
25
+
26
+ export function sha256OfBuffer(buf) {
27
+ return createHash('sha256').update(buf).digest('hex')
28
+ }
29
+
30
+ export async function sha256OfFile(filePath) {
31
+ const buf = await readFile(filePath)
32
+ return { hash: sha256OfBuffer(buf), size: buf.length }
33
+ }
34
+
35
+ export async function readTextFile(filePath, maxBytes = 2 * 1024 * 1024) {
36
+ const buf = await readFile(filePath)
37
+ if (buf.length > maxBytes) {
38
+ throw new Error(`File too large to index as text (${(buf.length / 1024 / 1024).toFixed(1)} MB)`)
39
+ }
40
+ return buf.toString('utf8')
41
+ }
42
+
43
+ const DEFAULT_IGNORE = new Set([
44
+ '.git', 'node_modules', 'dist', 'build', '.next', 'venv', '.venv',
45
+ '__pycache__', '.idea', '.vscode', '.dsh-project-memory', '.cache',
46
+ 'coverage', '.turbo', 'target', 'vendor', 'third_party', 'thirdparty', 'obj',
47
+ ])
48
+
49
+ export function walkDir(root, ignoreNames = DEFAULT_IGNORE) {
50
+ const out = []
51
+ const stack = [root]
52
+ while (stack.length) {
53
+ const dir = stack.pop()
54
+ let entries
55
+ try {
56
+ entries = readdirSync(dir, { withFileTypes: true })
57
+ } catch {
58
+ continue
59
+ }
60
+ for (const entry of entries) {
61
+ if (entry.name.startsWith('.') && entry.name !== '.') continue
62
+ if (entry.isDirectory()) {
63
+ if (ignoreNames.has(entry.name)) continue
64
+ stack.push(path.join(dir, entry.name))
65
+ } else {
66
+ out.push(path.join(dir, entry.name))
67
+ }
68
+ }
69
+ }
70
+ return out.sort()
71
+ }
72
+
73
+ export function isSupportedDoc(ext) {
74
+ return ['.pdf', '.md', '.markdown', '.txt'].includes(ext)
75
+ }
76
+
77
+ const DUMP_REFLECTION = /(?:==\s*TYPE\s|\bVersion=\d+\.\d+\.\d+\.\d+|loaded:\s*\S+,\s*Version=)/
78
+
79
+ export function looksLikeDump(text, maxBytes = 4096) {
80
+ if (!text) return false
81
+ const head = String(text).slice(0, maxBytes)
82
+ if (!/^(\uFEFF)?\s*={3,}/.test(head)) return false
83
+ return DUMP_REFLECTION.test(head)
84
+ }
85
+
86
+ export function isSupportedCode(ext) {
87
+ return [
88
+ '.js', '.mjs', '.cjs', '.ts', '.jsx', '.tsx', '.py', '.java', '.go',
89
+ '.rs', '.c', '.cpp', '.cc', '.h', '.hpp', '.cs', '.php', '.rb',
90
+ '.swift', '.kt', '.kts', '.sh', '.zsh',
91
+ ].includes(ext)
92
+ }
93
+
94
+ export function relativePath(root, filePath) {
95
+ return path.relative(root, filePath).split(path.sep).join('/')
96
+ }
97
+
98
+ export const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin'
99
+
100
+ export function storeKey(rel, platform = process.platform) {
101
+ return platform === 'win32' || platform === 'darwin' ? rel.toLowerCase() : rel
102
+ }
103
+
104
+ export function memoryRootFor(indexRoot, memoryDir) {
105
+ return path.join(indexRoot, memoryDir)
106
+ }
107
+
108
+ export function resolveIndexRoot(exec, explicitRoot) {
109
+ if (explicitRoot && explicitRoot.trim()) return path.resolve(explicitRoot)
110
+ const sessionCwd = exec?.agent?.session?.header?.cwd
111
+ if (sessionCwd) return path.resolve(sessionCwd)
112
+ return path.resolve(process.cwd())
113
+ }