@yolk_vat-y/dsh-project-memory 0.5.4 → 0.5.6
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 +128 -0
- package/README.md +98 -42
- package/README.zh-CN.md +98 -42
- package/client/client.js +111 -92
- package/client/client.js.map +1 -1
- package/package.json +7 -2
- package/scripts/bench-store.mjs +113 -0
- package/scripts/bench-synthetic.mjs +364 -0
- package/scripts/bench.mjs +396 -0
- package/src/audit.js +87 -0
- package/src/auto-inject.js +367 -51
- package/src/client/MemoryView.tsx +4 -2
- package/src/client/TaskComponents.tsx +7 -6
- package/src/client/TaskPanel.module.css +7 -0
- package/src/client/TaskPanel.tsx +12 -8
- package/src/client/task-ui-store.ts +11 -1
- package/src/index.js +19 -2
- package/src/insight-store.js +26 -4
- package/src/ops.js +210 -0
- package/src/parsers/pdfjs-parser.js +3 -0
- package/src/readiness.js +468 -0
- package/src/recall.js +217 -0
- package/src/store.js +20 -1
- package/src/tools/index-repo.js +7 -1
- package/src/tools/lesson-tools.js +31 -4
- package/src/tools/query-memory.js +61 -23
- package/src/tools/watch-repo.js +10 -1
- package/src/util/fs.js +41 -0
- package/src/watch.js +28 -4
package/src/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { rankEntries, rankExperience, tokenize, tokenizeRaw, extractCjkPhrases, makeSearchText } from './util/search.js'
|
|
5
|
+
import { backfillDerivedTriggers } from './readiness.js'
|
|
5
6
|
|
|
6
7
|
const FORMAT_FILE = 'format.json'
|
|
7
8
|
const INDEX_FILE = 'index.json'
|
|
@@ -166,6 +167,8 @@ export class ProjectMemoryStore {
|
|
|
166
167
|
const doc = loadJson(path.join(this.dir, INSIGHTS_FILE), null)
|
|
167
168
|
this.insights = doc && typeof doc === 'object' && Array.isArray(doc.items) ? doc : { version: 1, migratedAt: null, items: [] }
|
|
168
169
|
this._migrateExperienceToInsights()
|
|
170
|
+
// PR3:v1 → v2 懒回填派生 trigger(纯确定性、幂等;不调用模型,不改写已有字段)
|
|
171
|
+
if (backfillDerivedTriggers(this.insights)) this._dirtyInsights = true
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
_migrateExperienceToInsights() {
|
|
@@ -244,8 +247,22 @@ export class ProjectMemoryStore {
|
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
save() {
|
|
247
|
-
|
|
250
|
+
// 没有脏数据就不落盘。watch 每轮对每个根都无条件 commit → save;照旧执行的话,
|
|
251
|
+
// 末尾的 `_version++` + `_idfCache = null` 会打在跨实例共享的 store 上,
|
|
252
|
+
// 等于每 15 秒清空一次 IDF 缓存,废掉查询侧的 IDF 复用(v0.3.4 的 20x)。
|
|
253
|
+
const dirty =
|
|
254
|
+
this._dirtyShards.size > 0 ||
|
|
255
|
+
this._removedShards.size > 0 ||
|
|
256
|
+
this._dirtyExperience ||
|
|
257
|
+
this._dirtyInsights ||
|
|
258
|
+
this._dirtyTasks ||
|
|
259
|
+
this._dirtyBinding ||
|
|
260
|
+
this._dirtyWatch ||
|
|
261
|
+
!this._formatWritten
|
|
262
|
+
if (dirty) mkdirSync(this.dir, { recursive: true })
|
|
263
|
+
// 崩溃遗留的 *.tmp 无论有没有脏数据都顺手清掉(两次 readdir,自带 try/catch)
|
|
248
264
|
this.cleanStaleTmp()
|
|
265
|
+
if (!dirty) return
|
|
249
266
|
if (!this._formatWritten) {
|
|
250
267
|
writeJsonAtomic(path.join(this.dir, FORMAT_FILE), { version: 2, layout: 'sharded' })
|
|
251
268
|
this._formatWritten = true
|
|
@@ -272,6 +289,8 @@ export class ProjectMemoryStore {
|
|
|
272
289
|
this._dirtyExperience = false
|
|
273
290
|
}
|
|
274
291
|
if (this._dirtyInsights) {
|
|
292
|
+
// PR3:落盘即把格式标记推到 v2(v1 → v2 是纯增量:只多一个可选的 triggerDerived)
|
|
293
|
+
if (this.insights && this.insights.version !== 2) this.insights.version = 2
|
|
275
294
|
writeJsonAtomic(path.join(this.dir, INSIGHTS_FILE), this.insights)
|
|
276
295
|
this._dirtyInsights = false
|
|
277
296
|
}
|
package/src/tools/index-repo.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 { isSupportedCode, isSupportedDoc, looksLikeDump, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from '../util/fs.js'
|
|
4
|
+
import { assertIndexRoot, isSupportedCode, isSupportedDoc, looksLikeDump, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from '../util/fs.js'
|
|
5
5
|
import { buildDocEntries } from '../doc-pipeline.js'
|
|
6
6
|
import { docEntriesNeedBackfill } from '../doc-index.js'
|
|
7
7
|
import { scanSymbols } from '../symbols.js'
|
|
@@ -10,6 +10,9 @@ import { ProjectMemoryStore } from '../store.js'
|
|
|
10
10
|
import { onFileIndexed, isTypeScriptFile } from '../enhancer.js'
|
|
11
11
|
|
|
12
12
|
export async function indexRepository(ctx, config, root, { reindex = false } = {}) {
|
|
13
|
+
// 先校验根目录:缺了它,下面 `new ProjectMemoryStore(...).load()` 的 save() 会把
|
|
14
|
+
// 不存在的 root 连同 .dsh-project-memory 一起 mkdirSync 出来。
|
|
15
|
+
assertIndexRoot(root)
|
|
13
16
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
14
17
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
15
18
|
|
|
@@ -147,6 +150,9 @@ export function indexRepoTool(ctx, config) {
|
|
|
147
150
|
},
|
|
148
151
|
async execute(args) {
|
|
149
152
|
const root = path.resolve(args.root)
|
|
153
|
+
// 透传原始入参:Windows 风格路径在 POSIX 上会被 resolve 成 <cwd>/D:\...,
|
|
154
|
+
// 报错时要点明这是路径风格问题,而不是“目录被删了”。
|
|
155
|
+
assertIndexRoot(root, args.root)
|
|
150
156
|
return indexRepository(ctx, config, root, { reindex: Boolean(args.reindex) })
|
|
151
157
|
},
|
|
152
158
|
})
|
|
@@ -11,7 +11,7 @@ function sessionIdOf(exec) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function lessonTool(config) {
|
|
14
|
-
const kindDesc = `insight 语义:lesson(曾踩坑/纠偏, pattern→fix) | decision(权衡选型, choice→reason) | procedure(多步指南, steps
|
|
14
|
+
const kindDesc = `insight 语义:lesson(曾踩坑/纠偏, pattern→fix) | decision(权衡选型, choice→reason) | procedure(多步指南, steps) | experience(problem→solution,v0.4 兼容)。默认 lesson。所有 kind 都可带 trigger:命中即在动手前确定性注入。`
|
|
15
15
|
const scopeDesc =
|
|
16
16
|
'作用域:task(任务私有,随任务归档,不进共享注入) | project(项目资产) | global(个人能力库,跨项目,建议配 trigger 关键词以便将来技能命中)。' +
|
|
17
17
|
'解析顺序:显式 scope > task_id > 当前绑定任务 > project。'
|
|
@@ -35,11 +35,38 @@ export function lessonTool(config) {
|
|
|
35
35
|
type: 'object',
|
|
36
36
|
additionalProperties: false,
|
|
37
37
|
properties: {
|
|
38
|
-
|
|
38
|
+
when: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
properties: {
|
|
42
|
+
ops: { type: 'array', items: { type: 'string' }, description: '归一动作 id:file-write / file-delete / shell-run / git-commit / git-push / release / npm-publish / deploy / migrate / render-doc / index-doc / read-image / fetch-web / run-bench / query-memory。' },
|
|
43
|
+
writes: { type: 'array', items: { type: 'string' }, description: '本次要【写】的项目相对路径(禁扩展名/泛名 glob)。' },
|
|
44
|
+
intents: { type: 'array', items: { type: 'string' }, description: '人类消息里剥离引用后的意图词(CJK ≥2 字、拉丁 ≥5 字符且不含 _ . /)。' },
|
|
45
|
+
},
|
|
46
|
+
description: '唯一触发面:三者取或。',
|
|
47
|
+
},
|
|
48
|
+
guard: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
properties: {
|
|
52
|
+
paths: { type: 'array', items: { type: 'string' }, description: '收窄:写目标必须命中其中之一(自己不能触发)。' },
|
|
53
|
+
not_paths: { type: 'array', items: { type: 'string' } },
|
|
54
|
+
hosts: { type: 'array', items: { type: 'string' }, description: '例如 wsl(命令里出现 /mnt/* 或 powershell.exe)。' },
|
|
55
|
+
tags: { type: 'array', items: { type: 'string' }, description: '项目画像 tag 交集(画像未知时不过滤)。' },
|
|
56
|
+
},
|
|
57
|
+
description: '收窄条件:全部满足才注入。',
|
|
58
|
+
},
|
|
59
|
+
prevents: { type: 'string', description: '准入条件:不知道这条,这一步会做错什么。写不出来 → 不该进自动注入。' },
|
|
60
|
+
keywords: { type: 'array', items: { type: 'string' }, description: '【旧字段】降级为 intents 与被动召回排序。' },
|
|
39
61
|
symbols: { type: 'array', items: { type: 'string' } },
|
|
40
|
-
|
|
62
|
+
actions: { type: 'array', items: { type: 'string' }, description: '【旧字段】映射为 when.ops;死值丢弃并计入自检。' },
|
|
63
|
+
paths: { type: 'array', items: { type: 'string' }, description: '【旧字段】具体文件映射为 when.writes;扩展名/泛名 glob 丢弃。' },
|
|
64
|
+
scope: { type: 'array', items: { type: 'string' }, description: '【旧字段】默认忽略(值不在项目画像 tag 空间里)。' },
|
|
41
65
|
},
|
|
42
|
-
description:
|
|
66
|
+
description:
|
|
67
|
+
'authored trigger:命中即在**动手前**确定性注入。' +
|
|
68
|
+
'when = 唯一触发面(ops / writes / intents 取或);guard 只能收窄;prevents 是准入条件。' +
|
|
69
|
+
'没有 when 的条目不会自动推送,只出现在记忆目录里供按需拉取。',
|
|
43
70
|
},
|
|
44
71
|
task_id: { type: 'string', description: '目标任务 id(scope 缺省时优先于绑定任务)' },
|
|
45
72
|
files: { type: 'array', items: { type: 'string' }, description: '关联文件(项目相对路径)' },
|
|
@@ -4,9 +4,9 @@ import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
|
4
4
|
import { ProjectMemoryStore, storeOverview } from '../store.js'
|
|
5
5
|
import { expandQuery } from '../llm.js'
|
|
6
6
|
import { resolveRoute } from '../llm-route.js'
|
|
7
|
-
import {
|
|
7
|
+
import { GlobalStore, cfgInsight, defaultGlobalFile } from '../insight-store.js'
|
|
8
|
+
import { recallItems } from '../recall.js'
|
|
8
9
|
import { truncate } from '../util/text.js'
|
|
9
|
-
|
|
10
10
|
function toAbs(root, rel) {
|
|
11
11
|
return path.isAbsolute(rel) ? rel : path.join(root, rel)
|
|
12
12
|
}
|
|
@@ -15,9 +15,10 @@ export function queryMemoryTool(ctx, config) {
|
|
|
15
15
|
return defineTool({
|
|
16
16
|
name: 'query_memory',
|
|
17
17
|
description:
|
|
18
|
-
'Search persistent project memory: doc summaries and code symbol tables (indexed via index_doc/index_repo) ' +
|
|
19
|
-
'
|
|
20
|
-
'
|
|
18
|
+
'Search persistent project memory: doc summaries and code symbol tables (indexed via index_doc/index_repo), ' +
|
|
19
|
+
'experience notes (problem -> solution, saved via remember) and insights (lessons / decisions / procedures, ' +
|
|
20
|
+
'saved via save_lesson). Every hit returns its source path and line, or its insight id, so you can verify. ' +
|
|
21
|
+
'Docs are cross-linked to the code symbols they mention. ' +
|
|
21
22
|
'Use BEFORE grepping when you need orientation, a spec constraint, or a past decision.',
|
|
22
23
|
parameters: {
|
|
23
24
|
query: {
|
|
@@ -31,8 +32,8 @@ export function queryMemoryTool(ctx, config) {
|
|
|
31
32
|
},
|
|
32
33
|
type: {
|
|
33
34
|
type: 'string',
|
|
34
|
-
enum: ['all', 'doc', 'symbol', 'experience', 'task'],
|
|
35
|
-
description: 'Which memory layer to search. Default "all". "task" searches task records
|
|
35
|
+
enum: ['all', 'doc', 'symbol', 'experience', 'insight', 'task'],
|
|
36
|
+
description: 'Which memory layer to search. Default "all". "insight" searches lessons / decisions / procedures; "task" searches task records.',
|
|
36
37
|
},
|
|
37
38
|
limit: {
|
|
38
39
|
type: 'number',
|
|
@@ -57,18 +58,40 @@ export function queryMemoryTool(ctx, config) {
|
|
|
57
58
|
if (e.type === 'symbol') symbolById.set(e.id, e)
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
// 会话绑定决定 task 级 insight 的可见性;global 级始终可见。
|
|
62
|
+
const sessionId = exec?.agent?.session?.id
|
|
63
|
+
const boundTaskId = sessionId && typeof store.getBoundTaskId === 'function' ? store.getBoundTaskId(sessionId) : null
|
|
64
|
+
const globalStore = new GlobalStore(cfgInsight(config).globalFile || defaultGlobalFile()).load()
|
|
65
|
+
const wantMemory = type === 'all' || type === 'doc' || type === 'symbol'
|
|
66
|
+
const wantInsight = type === 'all' || type === 'insight'
|
|
67
|
+
// 一次 recall 覆盖全部层:doc/symbol/experience/insight 共用同一个检索核心(src/recall.js)。
|
|
68
|
+
const recalled = recallItems({
|
|
69
|
+
store,
|
|
70
|
+
globalStore,
|
|
71
|
+
queries,
|
|
72
|
+
boundTaskId,
|
|
73
|
+
limit,
|
|
74
|
+
layers: [
|
|
75
|
+
...(wantMemory ? (type === 'symbol' ? ['symbol'] : type === 'doc' ? ['doc'] : ['doc', 'symbol']) : []),
|
|
76
|
+
...(wantInsight ? ['insight'] : []),
|
|
77
|
+
...(type === 'all' || type === 'experience' ? ['experience'] : []),
|
|
78
|
+
],
|
|
79
|
+
})
|
|
61
80
|
|
|
62
81
|
const lines = []
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
if (wantMemory) {
|
|
83
|
+
// doc/symbol 同源同尺度:合并后按加权分排序(规范段提权已计入 weightedScore)
|
|
84
|
+
const memHits = recalled.layers
|
|
85
|
+
.filter((l) => l.layer === 'doc' || l.layer === 'symbol')
|
|
86
|
+
.flatMap((l) => l.hits)
|
|
87
|
+
.sort((a, b) => b.weightedScore - a.weightedScore)
|
|
88
|
+
.slice(0, limit)
|
|
89
|
+
if (memHits.length) {
|
|
90
|
+
const top = memHits[0].weightedScore || 1
|
|
68
91
|
lines.push(`## Memory (${type === 'all' ? 'docs + symbols' : type})`)
|
|
69
|
-
for (const {
|
|
92
|
+
for (const { item: e, weightedScore } of memHits) {
|
|
70
93
|
const absSource = e.sourceLine ? `${toAbs(root, e.sourcePath)}:${e.sourceLine}` : toAbs(root, e.sourcePath)
|
|
71
|
-
const rel = Math.round((
|
|
94
|
+
const rel = Math.round((weightedScore / top) * 100)
|
|
72
95
|
let summaryLine = `- ${e.summary}`
|
|
73
96
|
if (e.type === 'doc' && e.blindSpots) {
|
|
74
97
|
const queryTokens = queries.flatMap(q => q.split(/[\s\-_]+/)).map(t => t.toLowerCase()).filter(Boolean)
|
|
@@ -91,15 +114,28 @@ export function queryMemoryTool(ctx, config) {
|
|
|
91
114
|
}
|
|
92
115
|
}
|
|
93
116
|
}
|
|
117
|
+
if (wantInsight) {
|
|
118
|
+
const bucket = recalled.layers.find((l) => l.layer === 'insight')
|
|
119
|
+
if (bucket && bucket.hits.length) {
|
|
120
|
+
lines.push('## Insights (lessons / decisions / procedures)')
|
|
121
|
+
for (const { item: e, rel } of bucket.hits) {
|
|
122
|
+
const scope = e.scope ? `, scope: ${e.scope}` : ''
|
|
123
|
+
const files = e.files && e.files.length ? `\n- files: ${e.files.map((f) => toAbs(root, f)).join(', ')}` : ''
|
|
124
|
+
const conf = typeof e.confidence === 'number' ? `\n- confidence: ${e.confidence}` : ''
|
|
125
|
+
lines.push(
|
|
126
|
+
`### ${e.title} (score: ${Math.round(rel * 100)}, kind: ${e.kind}${scope}, id: ${e.insightId})\n- ${e.summary}${files}${conf}`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
94
131
|
if (type === 'all' || type === 'experience') {
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
const expTop = scoredExp[0].score || 1
|
|
132
|
+
const expBucket = recalled.layers.find((l) => l.layer === 'experience')
|
|
133
|
+
if (expBucket && expBucket.hits.length) {
|
|
98
134
|
lines.push(`## Experience (past problems -> solutions)`)
|
|
99
|
-
for (const { item: e,
|
|
135
|
+
for (const { item: e, rel } of expBucket.hits) {
|
|
100
136
|
const source = e.sourceFile ? ` (source: ${toAbs(root, e.sourceFile)})` : ''
|
|
101
137
|
lines.push(
|
|
102
|
-
`### Problem: ${e.problem} (score: ${Math.round(
|
|
138
|
+
`### Problem: ${e.problem} (score: ${Math.round(rel * 100)}, id: ${e.id})\n- solution: ${e.solution}${source}\n- updated: ${e.updatedAt}`,
|
|
103
139
|
)
|
|
104
140
|
}
|
|
105
141
|
}
|
|
@@ -135,9 +171,11 @@ export function queryMemoryTool(ctx, config) {
|
|
|
135
171
|
const hint =
|
|
136
172
|
type === 'experience'
|
|
137
173
|
? 'Note a fix with remember so it can be recalled next time.'
|
|
138
|
-
: type === '
|
|
139
|
-
? '
|
|
140
|
-
:
|
|
174
|
+
: type === 'insight'
|
|
175
|
+
? 'Save a lesson / decision / procedure with save_lesson so it can be recalled next time.'
|
|
176
|
+
: type === 'all'
|
|
177
|
+
? 'Index it first with index_repo / index_doc, or note a fix with remember or save_lesson.'
|
|
178
|
+
: 'Index it first with index_repo / index_doc.'
|
|
141
179
|
const tail =
|
|
142
180
|
overview.files === 0
|
|
143
181
|
? '. The store has never been indexed.'
|
package/src/tools/watch-repo.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { isUnwatchableRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
4
5
|
import { ProjectMemoryStore } from '../store.js'
|
|
5
6
|
|
|
6
7
|
export function watchRepoTool(watchManager, config) {
|
|
@@ -27,6 +28,14 @@ export function watchRepoTool(watchManager, config) {
|
|
|
27
28
|
},
|
|
28
29
|
async execute(args, exec) {
|
|
29
30
|
const root = path.resolve(args.root)
|
|
31
|
+
// 不存在的根不写进 watchlist:watch 每轮会 commit → save → mkdirSync,把它重新造出来
|
|
32
|
+
if (args.watch !== false && !existsSync(root)) {
|
|
33
|
+
return `Not watching: ${root} does not exist.`
|
|
34
|
+
}
|
|
35
|
+
// 文件系统根 / 共享临时目录不整体监听(子目录允许):会把无关程序和测试夹具的临时文件全扫进来
|
|
36
|
+
if (args.watch !== false && isUnwatchableRoot(root)) {
|
|
37
|
+
return `Refusing to watch ${root}: it is the filesystem root or the shared temp directory. Pass a project subdirectory instead.`
|
|
38
|
+
}
|
|
30
39
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
31
40
|
const sessionRoot = resolveIndexRoot(exec)
|
|
32
41
|
const sessionMemoryDir = memoryRootFor(sessionRoot, config.memoryDir)
|
package/src/util/fs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
|
+
import os from 'node:os'
|
|
4
5
|
import path from 'node:path'
|
|
5
6
|
|
|
6
7
|
export function assertReadableFile(filePath, maxFileSizeMb) {
|
|
@@ -23,6 +24,33 @@ export function assertReadableFile(filePath, maxFileSizeMb) {
|
|
|
23
24
|
return filePath
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/** Windows-style absolute path (`D:\dir`, `C:/dir`) — not absolute on POSIX. */
|
|
28
|
+
const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 校验索引根目录:必须存在且是目录。
|
|
32
|
+
*
|
|
33
|
+
* 之前 `index_repo` / `autoIndexOnFirstUse` 只做 `path.resolve()`:在 Linux/macOS 上传入
|
|
34
|
+
* Windows 风格路径(如 `D:\project\foo`)会被解析成相对路径 `<cwd>/D:\project\foo`,
|
|
35
|
+
* 随后 store 的 `mkdirSync` 把这个字面量目录(连同其 `.dsh-project-memory`)真的建出来。
|
|
36
|
+
* 这里在写盘前拒绝不存在的根;`requested` 只用于在报错里点明“这看起来是 Windows 路径”。
|
|
37
|
+
*/
|
|
38
|
+
export function assertIndexRoot(root, requested = root) {
|
|
39
|
+
let stats
|
|
40
|
+
try {
|
|
41
|
+
stats = statSync(root)
|
|
42
|
+
} catch {
|
|
43
|
+
const hint = process.platform !== 'win32' && WINDOWS_ABSOLUTE_PATH.test(String(requested))
|
|
44
|
+
? ` The argument looks like a Windows path, which is not absolute on ${process.platform}.`
|
|
45
|
+
: ''
|
|
46
|
+
throw new Error(`Index root does not exist: ${root}.${hint}`)
|
|
47
|
+
}
|
|
48
|
+
if (!stats.isDirectory()) {
|
|
49
|
+
throw new Error(`Index root is not a directory: ${root}`)
|
|
50
|
+
}
|
|
51
|
+
return root
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
export function sha256OfBuffer(buf) {
|
|
27
55
|
return createHash('sha256').update(buf).digest('hex')
|
|
28
56
|
}
|
|
@@ -117,6 +145,19 @@ export function memoryRootFor(indexRoot, memoryDir) {
|
|
|
117
145
|
return path.join(indexRoot, memoryDir)
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
/**
|
|
149
|
+
* 不该整体当「项目根」监听的目录:文件系统根,以及共享的 OS 临时目录。
|
|
150
|
+
* 监听后者会把无关程序(以及插件自己的测试夹具)的临时文件全扫进来,而且只要读过
|
|
151
|
+
* `/tmp` 下任意一个文件,findProjectRoot 的兜底就会把根解析成 `/tmp` 并自我固化。
|
|
152
|
+
* 根的**子目录**不受影响——项目放在临时目录的子目录里照常可监听。
|
|
153
|
+
*/
|
|
154
|
+
export function isUnwatchableRoot(root) {
|
|
155
|
+
if (typeof root !== 'string' || !root) return true
|
|
156
|
+
const abs = path.resolve(root)
|
|
157
|
+
if (abs === path.parse(abs).root) return true
|
|
158
|
+
return abs === path.resolve(os.tmpdir())
|
|
159
|
+
}
|
|
160
|
+
|
|
120
161
|
export function resolveIndexRoot(exec, explicitRoot) {
|
|
121
162
|
if (explicitRoot && explicitRoot.trim()) return path.resolve(explicitRoot)
|
|
122
163
|
const sessionCwd = exec?.agent?.session?.header?.cwd
|
package/src/watch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { isSupportedCode, isSupportedDoc, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from './util/fs.js'
|
|
3
|
+
import { isSupportedCode, isSupportedDoc, isUnwatchableRoot, memoryRootFor, readFileForIndex, relativePath, storeKey, walkDir } from './util/fs.js'
|
|
4
4
|
import { buildDocEntries } from './doc-pipeline.js'
|
|
5
5
|
import { docEntriesNeedBackfill } from './doc-index.js'
|
|
6
6
|
import { scanSymbols } from './symbols.js'
|
|
@@ -22,17 +22,34 @@ export class WatchManager {
|
|
|
22
22
|
// 用 load() 的返回值:storeCache 命中时 load() 返回的是缓存实例,忽略返回值会拿到空 store
|
|
23
23
|
const store = new ProjectMemoryStore(memoryRootFor(cwd, this.config.memoryDir)).load()
|
|
24
24
|
if (existsSync(store.dir)) {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
let dropped = 0
|
|
26
|
+
for (const root of [...store.watchlist]) {
|
|
27
|
+
if (typeof root !== 'string' || !root) continue
|
|
28
|
+
// 自愈剔除两类条目:已不存在的根(每轮白跑,还会把目录重新 mkdir 出来),
|
|
29
|
+
// 以及不该整体监听的文件系统根 / 共享临时目录(会把别人和测试的临时文件全扫进来)。
|
|
30
|
+
if (!existsSync(root) || isUnwatchableRoot(root)) {
|
|
31
|
+
store.removeWatch(root)
|
|
32
|
+
dropped++
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
this.addRoot(root)
|
|
27
36
|
}
|
|
37
|
+
if (dropped) store.save()
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
addRoot(root) {
|
|
42
|
+
// 根目录不存在就拒绝:否则每轮 poll 都会 commit → save → mkdirSync,
|
|
43
|
+
// 把一条历史遗留、已被删除的 watchlist 条目重新「创建」出来。
|
|
44
|
+
if (!existsSync(root)) return false
|
|
45
|
+
// 文件系统根 / 共享临时目录不整体监听(子目录允许)。
|
|
46
|
+
if (isUnwatchableRoot(root)) return false
|
|
32
47
|
if (!this.roots.has(root)) {
|
|
33
48
|
this.roots.set(root, {
|
|
34
49
|
store: new ProjectMemoryStore(memoryRootFor(root, this.config.memoryDir)).load(),
|
|
35
50
|
snapshot: {},
|
|
51
|
+
// rel → 上次已上报的错误信息。坏文件每轮都会重试,逐轮打印会刷屏。
|
|
52
|
+
failures: new Map(),
|
|
36
53
|
})
|
|
37
54
|
return true
|
|
38
55
|
}
|
|
@@ -125,15 +142,22 @@ export class WatchManager {
|
|
|
125
142
|
// Dump file - update snapshot so we don't re-hash next poll, but don't index
|
|
126
143
|
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, deleted: true, _sig: sig })
|
|
127
144
|
changed++
|
|
145
|
+
state.failures?.delete(rel)
|
|
128
146
|
continue
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, hash, size: stats.size, entries, type: isSupportedCode(ext) ? 'code' : 'doc', _sig: sig })
|
|
132
150
|
changed++
|
|
151
|
+
state.failures?.delete(rel)
|
|
133
152
|
} catch (err) {
|
|
134
153
|
// Index failed - rollback snapshot so next poll retries
|
|
135
154
|
delete state.snapshot[rel]
|
|
136
|
-
console.error
|
|
155
|
+
// 同一个文件的同一个错误只上报一次:坏文件每轮都会重试,逐轮 console.error 会刷屏
|
|
156
|
+
if (!state.failures) state.failures = new Map()
|
|
157
|
+
if (state.failures.get(rel) !== err.message) {
|
|
158
|
+
state.failures.set(rel, err.message)
|
|
159
|
+
console.error(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
|
|
160
|
+
}
|
|
137
161
|
continue
|
|
138
162
|
}
|
|
139
163
|
}
|