@yolk_vat-y/dsh-project-memory 0.5.6 → 0.5.7
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 +46 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +2 -2
- package/src/auto-inject.js +346 -281
- package/src/chunker.js +14 -6
- package/src/commands/insight-actions.js +18 -14
- package/src/commands/invocation.js +36 -0
- package/src/commands/task-actions.js +24 -39
- package/src/commands/tasks.js +18 -13
- package/src/enhancer.js +11 -2
- package/src/index-pipeline.js +119 -0
- package/src/insight-store.js +57 -28
- package/src/lazy.js +15 -46
- package/src/link.js +10 -1
- package/src/parsers/pdfjs-parser.js +2 -4
- package/src/project-profile.js +12 -5
- package/src/readiness.js +27 -3
- package/src/recall.js +28 -8
- package/src/setup/taskbridge.js +11 -9
- package/src/store.js +56 -17
- package/src/symbols.js +58 -19
- package/src/tools/forget.js +2 -1
- package/src/tools/index-doc.js +4 -1
- package/src/tools/index-repo.js +33 -86
- package/src/tools/lesson-tools.js +2 -1
- package/src/tools/query-memory.js +17 -3
- package/src/tools/remember.js +3 -1
- package/src/tools/task-tools.js +4 -1
- package/src/tools/watch-repo.js +9 -5
- package/src/util/fs.js +14 -0
- package/src/util/session-cache.js +72 -0
- package/src/watch.js +39 -81
package/src/tools/index-repo.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { statSync } from 'node:fs'
|
|
4
|
-
import { assertIndexRoot,
|
|
5
|
-
import {
|
|
6
|
-
import { docEntriesNeedBackfill } from '../doc-index.js'
|
|
7
|
-
import { scanSymbols } from '../symbols.js'
|
|
8
|
-
import { linkEntries } from '../link.js'
|
|
4
|
+
import { assertIndexRoot, memoryRootFor, relativePath, storeKey, walkDir } from '../util/fs.js'
|
|
5
|
+
import { DROP, OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from '../index-pipeline.js'
|
|
9
6
|
import { ProjectMemoryStore } from '../store.js'
|
|
10
7
|
import { onFileIndexed, isTypeScriptFile } from '../enhancer.js'
|
|
11
8
|
|
|
@@ -16,109 +13,59 @@ export async function indexRepository(ctx, config, root, { reindex = false } = {
|
|
|
16
13
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
17
14
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
18
15
|
|
|
19
|
-
const files = walkDir(root)
|
|
20
16
|
const seen = new Set()
|
|
17
|
+
const updates = []
|
|
18
|
+
const failures = []
|
|
21
19
|
let indexed = 0
|
|
22
20
|
let updated = 0
|
|
23
21
|
let skipped = 0
|
|
24
|
-
let removed = 0
|
|
25
|
-
const failures = []
|
|
26
|
-
|
|
27
|
-
// First pass: collect all file info and compute hashes/entries (async work outside commit)
|
|
28
|
-
const fileUpdates = []
|
|
29
22
|
|
|
30
|
-
for (const filePath of
|
|
23
|
+
for (const filePath of walkDir(root)) {
|
|
31
24
|
const rel = storeKey(relativePath(root, filePath))
|
|
32
25
|
seen.add(rel)
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
26
|
+
const kind = fileKind(path.extname(filePath).toLowerCase())
|
|
27
|
+
if (!kind) continue
|
|
35
28
|
|
|
36
29
|
try {
|
|
37
30
|
const size = statSync(filePath).size
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
const record = store.fileRecord(rel)
|
|
32
|
+
const plan = await planFileIndex({ rel, filePath, kind, config, record, existingEntries: store.entries[rel], size, force: reindex })
|
|
33
|
+
if (plan.key === UNCHANGED) {
|
|
41
34
|
skipped++
|
|
42
35
|
continue
|
|
43
36
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// 旧 store 的 doc 条目缺 terms → 即使哈希未变也重抽一次(一次性回填)
|
|
48
|
-
const needsBackfill = isSupportedDoc(ext) && docEntriesNeedBackfill(store.entries[rel])
|
|
49
|
-
if (!reindex && existing && existing.sha256 === hash && !needsBackfill) {
|
|
37
|
+
if (plan.key === OVERSIZE) {
|
|
38
|
+
// 体积超限的代码文件:旧记录一律清掉,避免检索命中一个已经不索引的文件。
|
|
39
|
+
updates.push({ rel, drop: true })
|
|
50
40
|
skipped++
|
|
51
41
|
continue
|
|
52
42
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
updated++
|
|
59
|
-
} else {
|
|
60
|
-
const content = buffer.toString('utf8')
|
|
61
|
-
if (looksLikeDump(content)) {
|
|
62
|
-
fileUpdates.push({ rel, deleted: true })
|
|
63
|
-
skipped++
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
entries = await buildDocEntries(rel, filePath, {
|
|
67
|
-
chunkChars: config.chunkChars,
|
|
68
|
-
maxChunks: config.maxChunksPerFile,
|
|
69
|
-
maxFileSizeMb: config.maxFileSizeMb,
|
|
70
|
-
maxPdfPages: config.maxPdfPages,
|
|
71
|
-
})
|
|
72
|
-
if (entries === null) {
|
|
73
|
-
fileUpdates.push({ rel, deleted: true })
|
|
74
|
-
skipped++
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
|
-
fileUpdates.push({ rel, expectedHash: existing?.sha256, hash, size, entries, type: 'doc' })
|
|
78
|
-
indexed++
|
|
79
|
-
}
|
|
80
|
-
} catch (err) {
|
|
43
|
+
updates.push(toFileUpdate(rel, plan, record))
|
|
44
|
+
if (plan.key === DROP) skipped++
|
|
45
|
+
else if (plan.type === 'code') updated++
|
|
46
|
+
else indexed++
|
|
47
|
+
} catch {
|
|
81
48
|
failures.push(rel)
|
|
82
49
|
}
|
|
83
50
|
}
|
|
84
51
|
|
|
85
|
-
//
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Remove files not seen
|
|
96
|
-
for (const rel of Object.keys(s.files)) {
|
|
97
|
-
if (!seen.has(rel)) {
|
|
98
|
-
s.removeFile(rel)
|
|
99
|
-
removed++
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
linkEntries(s)
|
|
104
|
-
const stats = s.stats()
|
|
105
|
-
let report =
|
|
106
|
-
`Indexed project: ${root}\n` +
|
|
107
|
-
`docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
|
|
108
|
-
`memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
|
|
109
|
-
if (failures.length) {
|
|
110
|
-
report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
|
|
111
|
-
}
|
|
112
|
-
return report
|
|
113
|
-
})
|
|
52
|
+
// 单事务提交:写入 + 清理本轮未见到的旧条目 + 重建链接。
|
|
53
|
+
const { removed } = commitFileUpdates(store, { updates, unseen: seen })
|
|
54
|
+
const stats = store.stats()
|
|
55
|
+
let report =
|
|
56
|
+
`Indexed project: ${root}\n` +
|
|
57
|
+
`docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
|
|
58
|
+
`memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
|
|
59
|
+
if (failures.length) {
|
|
60
|
+
report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
|
|
61
|
+
}
|
|
114
62
|
|
|
115
63
|
// Trigger TS enhancement for code files (TS/JS only)
|
|
116
|
-
for (const update of
|
|
117
|
-
if (update.type
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
64
|
+
for (const update of updates) {
|
|
65
|
+
if (update.type !== 'code') continue
|
|
66
|
+
const filePath = path.join(root, update.rel)
|
|
67
|
+
if (isTypeScriptFile(filePath)) {
|
|
68
|
+
onFileIndexed(store, update.rel, filePath, config, root)
|
|
122
69
|
}
|
|
123
70
|
}
|
|
124
71
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
2
|
+
import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
import { truncate } from '../util/text.js'
|
|
5
5
|
import { cfgInsight, saveInsight, defaultGlobalFile, GlobalStore } from '../insight-store.js'
|
|
@@ -77,6 +77,7 @@ export function lessonTool(config) {
|
|
|
77
77
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
78
78
|
async execute(args, exec) {
|
|
79
79
|
const root = resolveIndexRoot(exec, args.root)
|
|
80
|
+
assertIndexRoot(root, args.root || root)
|
|
80
81
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
81
82
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
82
83
|
const cfg = cfgInsight(config)
|
|
@@ -11,6 +11,15 @@ function toAbs(root, rel) {
|
|
|
11
11
|
return path.isAbsolute(rel) ? rel : path.join(root, rel)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** 步骤在历史数据里可能是字符串或 {content|text, status}:TaskBridge/注入/反思都按 content||text 读。 */
|
|
15
|
+
function stepContent(s) {
|
|
16
|
+
if (typeof s === 'string') return s
|
|
17
|
+
return s?.content ?? s?.text ?? ''
|
|
18
|
+
}
|
|
19
|
+
function stepStatus(s) {
|
|
20
|
+
return typeof s === 'string' ? 'pending' : (s?.status || 'pending')
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export function queryMemoryTool(ctx, config) {
|
|
15
24
|
return defineTool({
|
|
16
25
|
name: 'query_memory',
|
|
@@ -46,6 +55,11 @@ export function queryMemoryTool(ctx, config) {
|
|
|
46
55
|
},
|
|
47
56
|
async execute(args, exec) {
|
|
48
57
|
const root = resolveIndexRoot(exec, args.root)
|
|
58
|
+
// 空查询不是"全部":recallItems 会过滤空串,rankEntriesStreaming 随即返回
|
|
59
|
+
// entries.slice(0, limit)(任意条目、分数 0),task 分支的 includes('') 更是命中所有任务。
|
|
60
|
+
if (!String(args.query ?? '').trim()) {
|
|
61
|
+
return 'query must not be empty. Use memory_stats to see what the store contains.'
|
|
62
|
+
}
|
|
49
63
|
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
50
64
|
const type = args.type || 'all'
|
|
51
65
|
const limit = Math.max(1, Math.min(Number(args.limit) || 8, 20))
|
|
@@ -146,16 +160,16 @@ export function queryMemoryTool(ctx, config) {
|
|
|
146
160
|
if (type === 'task') {
|
|
147
161
|
const q = (queries[0] || '').toLowerCase()
|
|
148
162
|
const matched = tasks
|
|
149
|
-
.filter((t) => !t.archived && (t.title.toLowerCase().includes(q) || (t.steps || []).some((s) => s.
|
|
163
|
+
.filter((t) => !t.archived && (t.title.toLowerCase().includes(q) || (t.steps || []).some((s) => stepContent(s).toLowerCase().includes(q)) || (t.files || []).some((f) => f.toLowerCase().includes(q))))
|
|
150
164
|
.slice(0, limit)
|
|
151
165
|
if (!matched.length) {
|
|
152
166
|
return `任务记录: 0 套匹配 "${args.query}"(list_tasks 查看全部,select_task 续做)`
|
|
153
167
|
}
|
|
154
168
|
for (const t of matched) {
|
|
155
|
-
const done = (t.steps || []).filter((s) => s
|
|
169
|
+
const done = (t.steps || []).filter((s) => stepStatus(s) === 'completed').length
|
|
156
170
|
const total = (t.steps || []).length
|
|
157
171
|
const stepsText = (t.steps || []).length
|
|
158
|
-
? (t.steps || []).map((s) => `- [${s
|
|
172
|
+
? (t.steps || []).map((s) => `- [${stepStatus(s) === 'completed' ? 'x' : stepStatus(s) === 'in_progress' ? '*' : ' '}] ${stepContent(s)}`).join('\n')
|
|
159
173
|
: '(无步骤)'
|
|
160
174
|
const files = (t.files || []).slice(0, 8).join(', ')
|
|
161
175
|
lines.push(`### ${t.title} (${done}/${total} 完成)\n${stepsText}\n- 文件: ${files || '无'}`)
|
package/src/tools/remember.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
2
|
+
import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
|
|
5
5
|
export function rememberTool(config) {
|
|
@@ -35,6 +35,8 @@ export function rememberTool(config) {
|
|
|
35
35
|
},
|
|
36
36
|
async execute(args, exec) {
|
|
37
37
|
const root = resolveIndexRoot(exec, args.root)
|
|
38
|
+
// 写盘前校验根:否则一个拼错的 root 会被 mkdir 成字面量目录(连同 .dsh-project-memory)。
|
|
39
|
+
assertIndexRoot(root, args.root || root)
|
|
38
40
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
39
41
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
40
42
|
return store.commit((s) => {
|
package/src/tools/task-tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
2
|
+
import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
3
|
import { ProjectMemoryStore } from '../store.js'
|
|
4
4
|
import { truncate } from '../util/text.js'
|
|
5
5
|
import { genTaskId, hash8, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
|
|
@@ -40,6 +40,7 @@ export function listTasksTool(config) {
|
|
|
40
40
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
41
41
|
async execute(args, exec) {
|
|
42
42
|
const root = resolveIndexRoot(exec, args.root)
|
|
43
|
+
assertIndexRoot(root, args.root || root)
|
|
43
44
|
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
44
45
|
const tasks = store.getTasks()
|
|
45
46
|
if (!tasks.length) {
|
|
@@ -66,6 +67,7 @@ export function selectTaskTool(config, host) {
|
|
|
66
67
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
67
68
|
async execute(args, exec) {
|
|
68
69
|
const root = resolveIndexRoot(exec, args.root)
|
|
70
|
+
assertIndexRoot(root, args.root || root)
|
|
69
71
|
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
70
72
|
const sid = sessionIdOf(exec)
|
|
71
73
|
const now = new Date().toISOString()
|
|
@@ -184,6 +186,7 @@ export function archiveTaskTool(config, host) {
|
|
|
184
186
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
185
187
|
async execute(args, exec) {
|
|
186
188
|
const root = resolveIndexRoot(exec, args.root)
|
|
189
|
+
assertIndexRoot(root, args.root || root)
|
|
187
190
|
const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
|
|
188
191
|
const task = store.getTask(args.taskId)
|
|
189
192
|
if (!task) return truncate(JSON.stringify({ success: false, error: 'Task not found' }), config.maxOutputChars)
|
package/src/tools/watch-repo.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import { isUnwatchableRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
3
|
+
import { assertIndexRoot, isUnwatchableRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
5
4
|
import { ProjectMemoryStore } from '../store.js'
|
|
6
5
|
|
|
7
6
|
export function watchRepoTool(watchManager, config) {
|
|
@@ -28,9 +27,14 @@ export function watchRepoTool(watchManager, config) {
|
|
|
28
27
|
},
|
|
29
28
|
async execute(args, exec) {
|
|
30
29
|
const root = path.resolve(args.root)
|
|
31
|
-
// 不存在的根不写进 watchlist:watch 每轮会 commit → save → mkdirSync
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
// 不存在的根不写进 watchlist:watch 每轮会 commit → save → mkdirSync,把它重新造出来。
|
|
31
|
+
// 根是**文件**时同样要拦:否则 save() 的 mkdirSync 会抛裸 ENOTDIR。
|
|
32
|
+
if (args.watch !== false) {
|
|
33
|
+
try {
|
|
34
|
+
assertIndexRoot(root, args.root)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return `Not watching: ${err.message}`
|
|
37
|
+
}
|
|
34
38
|
}
|
|
35
39
|
// 文件系统根 / 共享临时目录不整体监听(子目录允许):会把无关程序和测试夹具的临时文件全扫进来
|
|
36
40
|
if (args.watch !== false && isUnwatchableRoot(root)) {
|
package/src/util/fs.js
CHANGED
|
@@ -73,8 +73,22 @@ export function readFileForIndex(filePath) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export async function readTextFile(filePath, maxBytes = 2 * 1024 * 1024) {
|
|
76
|
+
// 先 stat 再读:大小上限的意义就是"别把整个文件读进内存"。旧写法先 readFile 再比较,
|
|
77
|
+
// 127 MB 的文件会先把 127 MB 拉进 RSS 再抛错(watch/lazy 每轮重试,反复发生)。
|
|
78
|
+
if (Number.isFinite(maxBytes)) {
|
|
79
|
+
let size = 0
|
|
80
|
+
try {
|
|
81
|
+
size = statSync(filePath).size
|
|
82
|
+
} catch {
|
|
83
|
+
size = 0 // 交给下面的 readFile 报真实错误(不存在 / 无权限)
|
|
84
|
+
}
|
|
85
|
+
if (size > maxBytes) {
|
|
86
|
+
throw new Error(`File too large to index as text (${(size / 1024 / 1024).toFixed(1)} MB)`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
76
89
|
const buf = await readFile(filePath)
|
|
77
90
|
if (buf.length > maxBytes) {
|
|
91
|
+
// stat 与 read 之间文件被改大:兜底再判一次
|
|
78
92
|
throw new Error(`File too large to index as text (${(buf.length / 1024 / 1024).toFixed(1)} MB)`)
|
|
79
93
|
}
|
|
80
94
|
return buf.toString('utf8')
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话级状态的容量管理。
|
|
3
|
+
*
|
|
4
|
+
* 注入引擎有六张「每会话一份」的表(注入指纹 / 丢弃签名 / 已注入条目 / 步号 / 额度 / 动作观察窗)。
|
|
5
|
+
* 它们各自都需要淘汰最旧会话,于是同一个 `while (map.size > CAP) delete(oldest)` 被抄了五遍——
|
|
6
|
+
* 容量上限写在五处,抄漏一处就是无界增长,dispose 时也漏清两张表。这里把容量收成唯一事实:
|
|
7
|
+
* 新增一种会话状态只需换一个 create 函数,不必再想淘汰。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_SESSIONS = 200
|
|
11
|
+
|
|
12
|
+
/** 容量受限的 Map:写入超过 max 时按插入顺序淘汰最旧项(重写已有键会刷新它的位置)。 */
|
|
13
|
+
export class BoundedMap extends Map {
|
|
14
|
+
constructor(max, entries) {
|
|
15
|
+
super(entries)
|
|
16
|
+
this.max = max
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
set(key, value) {
|
|
20
|
+
if (this.has(key)) super.delete(key)
|
|
21
|
+
super.set(key, value)
|
|
22
|
+
while (this.size > this.max) super.delete(this.keys().next().value)
|
|
23
|
+
return this
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 会话 → 值,容量按会话数封顶(淘汰最久未触碰的会话)。
|
|
29
|
+
* - `ensure(id)`:读不到就用 create 建一份并记下,适合数组 / 计数 / 额度这类累加器;
|
|
30
|
+
* - `peek(id)` / `set(id, v)`:适合只需读写的值(指纹、签名),不假设默认值。
|
|
31
|
+
*/
|
|
32
|
+
export class SessionCache {
|
|
33
|
+
constructor({ create = () => undefined, maxSessions = DEFAULT_MAX_SESSIONS } = {}) {
|
|
34
|
+
this.create = create
|
|
35
|
+
this.store = new BoundedMap(maxSessions)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 只读查找;不存在返回 undefined(不创建)。读到即刷新其"最近使用"位置。 */
|
|
39
|
+
peek(sessionId) {
|
|
40
|
+
const value = this.store.get(sessionId)
|
|
41
|
+
if (value !== undefined) this.store.set(sessionId, value)
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 读取并(必要时)创建:create 只会在该会话第一次出现时调用。 */
|
|
46
|
+
ensure(sessionId) {
|
|
47
|
+
const value = this.store.get(sessionId)
|
|
48
|
+
if (value !== undefined) {
|
|
49
|
+
this.store.set(sessionId, value)
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
const created = this.create(sessionId)
|
|
53
|
+
this.store.set(sessionId, created)
|
|
54
|
+
return created
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
set(sessionId, value) {
|
|
58
|
+
this.store.set(sessionId, value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
delete(sessionId) {
|
|
62
|
+
return this.store.delete(sessionId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
clear() {
|
|
66
|
+
this.store.clear()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get size() {
|
|
70
|
+
return this.store.size
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/watch.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { docEntriesNeedBackfill } from './doc-index.js'
|
|
6
|
-
import { scanSymbols } from './symbols.js'
|
|
7
|
-
import { linkEntries } from './link.js'
|
|
3
|
+
import { isUnwatchableRoot, memoryRootFor, relativePath, storeKey, walkDir } from './util/fs.js'
|
|
4
|
+
import { OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from './index-pipeline.js'
|
|
8
5
|
import { ProjectMemoryStore } from './store.js'
|
|
9
6
|
import { onFileChanged, isTypeScriptFile } from './enhancer.js'
|
|
10
7
|
|
|
@@ -94,18 +91,16 @@ export class WatchManager {
|
|
|
94
91
|
}
|
|
95
92
|
|
|
96
93
|
async pollRoot(root, state) {
|
|
97
|
-
const files = walkDir(root)
|
|
98
94
|
const seen = new Set()
|
|
99
|
-
|
|
95
|
+
const updates = []
|
|
96
|
+
// rel → 本轮采集的 mtime:size。提交成功后才落进快照;CAS 失败的条目留在原地等下一轮。
|
|
97
|
+
const signatures = new Map()
|
|
100
98
|
|
|
101
|
-
|
|
102
|
-
const fileUpdates = []
|
|
103
|
-
|
|
104
|
-
for (const filePath of files) {
|
|
99
|
+
for (const filePath of walkDir(root)) {
|
|
105
100
|
const rel = storeKey(relativePath(root, filePath))
|
|
106
101
|
seen.add(rel)
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
102
|
+
const kind = fileKind(path.extname(filePath).toLowerCase())
|
|
103
|
+
if (!kind) continue
|
|
109
104
|
|
|
110
105
|
let stats
|
|
111
106
|
try {
|
|
@@ -116,86 +111,49 @@ export class WatchManager {
|
|
|
116
111
|
const sig = `${stats.mtimeMs}:${stats.size}`
|
|
117
112
|
if (state.snapshot[rel] === sig) continue
|
|
118
113
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// 单次读盘:同一 buffer 供哈希与正文使用
|
|
124
|
-
const { hash, buffer } = readFileForIndex(filePath)
|
|
125
|
-
const existing = state.store.fileRecord(rel)
|
|
126
|
-
// 旧 store 的 doc 条目缺 terms → 一次性回填(即使哈希未变)
|
|
127
|
-
const needsBackfill = isSupportedDoc(ext) && docEntriesNeedBackfill(state.store.entries[rel])
|
|
128
|
-
if (existing && existing.sha256 === hash && !needsBackfill) continue
|
|
129
|
-
|
|
114
|
+
const record = state.store.fileRecord(rel)
|
|
115
|
+
let plan
|
|
130
116
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
})
|
|
141
|
-
if (entries === null) {
|
|
142
|
-
// Dump file - update snapshot so we don't re-hash next poll, but don't index
|
|
143
|
-
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, deleted: true, _sig: sig })
|
|
144
|
-
changed++
|
|
145
|
-
state.failures?.delete(rel)
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, hash, size: stats.size, entries, type: isSupportedCode(ext) ? 'code' : 'doc', _sig: sig })
|
|
150
|
-
changed++
|
|
151
|
-
state.failures?.delete(rel)
|
|
117
|
+
plan = await planFileIndex({
|
|
118
|
+
rel,
|
|
119
|
+
filePath,
|
|
120
|
+
kind,
|
|
121
|
+
config: this.config,
|
|
122
|
+
record,
|
|
123
|
+
existingEntries: state.store.entries[rel],
|
|
124
|
+
size: stats.size,
|
|
125
|
+
})
|
|
152
126
|
} catch (err) {
|
|
153
|
-
//
|
|
154
|
-
delete state.snapshot[rel]
|
|
155
|
-
// 同一个文件的同一个错误只上报一次:坏文件每轮都会重试,逐轮 console.error 会刷屏
|
|
156
|
-
if (!state.failures) state.failures = new Map()
|
|
127
|
+
// 坏文件每轮都会重试:同一个文件的同一个错误只上报一次,否则逐轮 console.error 刷屏。
|
|
157
128
|
if (state.failures.get(rel) !== err.message) {
|
|
158
129
|
state.failures.set(rel, err.message)
|
|
159
130
|
console.error(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
|
|
160
131
|
}
|
|
161
132
|
continue
|
|
162
133
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (result.skipped) {
|
|
170
|
-
// CAS failed - file was modified concurrently, rollback snapshot to retry next poll
|
|
171
|
-
delete state.snapshot[update.rel]
|
|
172
|
-
// Mark this update to skip snapshot update after commit
|
|
173
|
-
update._skipSnapshot = true
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Remove deleted files
|
|
178
|
-
for (const rel of Object.keys(s.files)) {
|
|
179
|
-
if (!seen.has(rel)) {
|
|
180
|
-
s.removeFile(rel)
|
|
181
|
-
}
|
|
134
|
+
state.failures.delete(rel)
|
|
135
|
+
// 内容未变 / 体积超限也要落快照:否则 mtime:size 快路径永远命中不了,
|
|
136
|
+
// 每次 poll 都要把整个仓库重读一遍再哈希(watch 默认开、15s 一轮)。
|
|
137
|
+
if (plan.key === UNCHANGED || plan.key === OVERSIZE) {
|
|
138
|
+
state.snapshot[rel] = sig
|
|
139
|
+
continue
|
|
182
140
|
}
|
|
183
141
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
})
|
|
142
|
+
signatures.set(rel, sig)
|
|
143
|
+
updates.push(toFileUpdate(rel, plan, record))
|
|
144
|
+
}
|
|
188
145
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
146
|
+
// 单事务写盘;CAS 失败的条目本轮不落快照,下一轮自然重试。
|
|
147
|
+
const { stale } = commitFileUpdates(state.store, { updates, unseen: seen, link: updates.length > 0 })
|
|
148
|
+
const failed = new Set(stale)
|
|
149
|
+
for (const update of updates) {
|
|
150
|
+
if (failed.has(update.rel)) continue
|
|
151
|
+
state.snapshot[update.rel] = signatures.get(update.rel)
|
|
152
|
+
if (update.type !== 'code') continue
|
|
153
|
+
const filePath = path.join(root, update.rel)
|
|
154
|
+
if (isTypeScriptFile(filePath)) {
|
|
155
|
+
onFileChanged(state.store, update.rel, filePath, this.config, root)
|
|
197
156
|
}
|
|
198
|
-
state.snapshot[update.rel] = update._sig
|
|
199
157
|
}
|
|
200
158
|
}
|
|
201
159
|
}
|