@yolk_vat-y/dsh-project-memory 0.5.3 → 0.5.5
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 +75 -42
- package/README.zh-CN.md +75 -42
- package/client/client.js +111 -92
- package/client/client.js.map +1 -1
- package/package.json +5 -2
- package/scripts/bench-store.mjs +113 -0
- package/scripts/bench-synthetic.mjs +364 -0
- package/scripts/bench.mjs +396 -0
- package/src/auto-inject.js +255 -52
- 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/commands/task-actions.js +3 -2
- package/src/doc-index.js +71 -0
- package/src/doc-pipeline.js +22 -27
- package/src/index.js +33 -3
- package/src/insight-store.js +6 -4
- package/src/lazy.js +4 -2
- package/src/link.js +2 -1
- package/src/llm-route.js +95 -0
- package/src/llm.js +20 -61
- package/src/parsers/pdfjs-parser.js +3 -0
- package/src/readiness.js +224 -0
- package/src/recall.js +217 -0
- package/src/reflection-pipeline.js +10 -4
- package/src/setup/taskbridge.js +8 -2
- package/src/store.js +21 -2
- package/src/tools/index-doc.js +3 -3
- package/src/tools/index-repo.js +14 -4
- package/src/tools/lesson-tools.js +7 -2
- package/src/tools/query-memory.js +66 -25
- package/src/tools/task-tools.js +3 -2
- package/src/tools/watch-repo.js +10 -1
- package/src/util/fs.js +41 -0
- package/src/util/search.js +2 -0
- package/src/util/text.js +13 -0
- package/src/watch.js +33 -6
|
@@ -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。'
|
|
@@ -37,9 +37,14 @@ export function lessonTool(config) {
|
|
|
37
37
|
properties: {
|
|
38
38
|
keywords: { type: 'array', items: { type: 'string' } },
|
|
39
39
|
symbols: { type: 'array', items: { type: 'string' } },
|
|
40
|
+
actions: { type: 'array', items: { type: 'string' } },
|
|
41
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
40
42
|
scope: { type: 'array', items: { type: 'string' } },
|
|
41
43
|
},
|
|
42
|
-
description:
|
|
44
|
+
description:
|
|
45
|
+
'authored trigger(所有 kind 通用):命中即在**动手前**确定性注入本条。' +
|
|
46
|
+
'keywords/symbols 匹配人类消息与工具参数;actions 用归一 id(git-commit / npm-publish / go-public / deploy / delete / migrate …);' +
|
|
47
|
+
'paths 用 glob(README* / CHANGELOG* / .gitignore);scope 按项目画像 tags 过滤。留空则只可能作为统计提示注入。',
|
|
43
48
|
},
|
|
44
49
|
task_id: { type: 'string', description: '目标任务 id(scope 缺省时优先于绑定任务)' },
|
|
45
50
|
files: { type: 'array', items: { type: 'string' }, description: '关联文件(项目相对路径)' },
|
|
@@ -3,9 +3,10 @@ import path from 'node:path'
|
|
|
3
3
|
import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
|
|
4
4
|
import { ProjectMemoryStore, storeOverview } from '../store.js'
|
|
5
5
|
import { expandQuery } from '../llm.js'
|
|
6
|
-
import {
|
|
6
|
+
import { resolveRoute } from '../llm-route.js'
|
|
7
|
+
import { GlobalStore, cfgInsight, defaultGlobalFile } from '../insight-store.js'
|
|
8
|
+
import { recallItems } from '../recall.js'
|
|
7
9
|
import { truncate } from '../util/text.js'
|
|
8
|
-
|
|
9
10
|
function toAbs(root, rel) {
|
|
10
11
|
return path.isAbsolute(rel) ? rel : path.join(root, rel)
|
|
11
12
|
}
|
|
@@ -14,9 +15,10 @@ export function queryMemoryTool(ctx, config) {
|
|
|
14
15
|
return defineTool({
|
|
15
16
|
name: 'query_memory',
|
|
16
17
|
description:
|
|
17
|
-
'Search persistent project memory: doc summaries and code symbol tables (indexed via index_doc/index_repo) ' +
|
|
18
|
-
'
|
|
19
|
-
'
|
|
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. ' +
|
|
20
22
|
'Use BEFORE grepping when you need orientation, a spec constraint, or a past decision.',
|
|
21
23
|
parameters: {
|
|
22
24
|
query: {
|
|
@@ -30,8 +32,8 @@ export function queryMemoryTool(ctx, config) {
|
|
|
30
32
|
},
|
|
31
33
|
type: {
|
|
32
34
|
type: 'string',
|
|
33
|
-
enum: ['all', 'doc', 'symbol', 'experience', 'task'],
|
|
34
|
-
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.',
|
|
35
37
|
},
|
|
36
38
|
limit: {
|
|
37
39
|
type: 'number',
|
|
@@ -49,25 +51,47 @@ export function queryMemoryTool(ctx, config) {
|
|
|
49
51
|
const limit = Math.max(1, Math.min(Number(args.limit) || 8, 20))
|
|
50
52
|
|
|
51
53
|
const queries = config.llmQueryExpansion
|
|
52
|
-
? await expandQuery(ctx.llm, args.query, config.expansionCount)
|
|
54
|
+
? await expandQuery(ctx.llm, args.query, config.expansionCount, { route: resolveRoute(exec, config) })
|
|
53
55
|
: [args.query]
|
|
54
56
|
const symbolById = new Map()
|
|
55
57
|
for (const e of store.allEntries()) {
|
|
56
58
|
if (e.type === 'symbol') symbolById.set(e.id, e)
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
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
|
+
})
|
|
60
80
|
|
|
61
81
|
const lines = []
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
91
|
lines.push(`## Memory (${type === 'all' ? 'docs + symbols' : type})`)
|
|
68
|
-
for (const {
|
|
92
|
+
for (const { item: e, weightedScore } of memHits) {
|
|
69
93
|
const absSource = e.sourceLine ? `${toAbs(root, e.sourcePath)}:${e.sourceLine}` : toAbs(root, e.sourcePath)
|
|
70
|
-
const rel = Math.round((
|
|
94
|
+
const rel = Math.round((weightedScore / top) * 100)
|
|
71
95
|
let summaryLine = `- ${e.summary}`
|
|
72
96
|
if (e.type === 'doc' && e.blindSpots) {
|
|
73
97
|
const queryTokens = queries.flatMap(q => q.split(/[\s\-_]+/)).map(t => t.toLowerCase()).filter(Boolean)
|
|
@@ -77,7 +101,9 @@ export function queryMemoryTool(ctx, config) {
|
|
|
77
101
|
summaryLine += `\n- ⚠️ 摘要未覆盖:${e.blindSpots.replace(/^\s*\/\/\s*未覆盖[::]\s*/, '')}。建议读原文 ${absSource}`
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
|
-
|
|
104
|
+
// 条目状态占位(可证伪状态机落地前恒为 exact)。
|
|
105
|
+
// 先立字段,后续状态机到位时只改值、不改输出契约。
|
|
106
|
+
lines.push(`### ${e.title} (score: ${rel})\n- source: ${absSource}\n- status: ${e.status || 'exact'}\n${summaryLine}`)
|
|
81
107
|
if (e.type === 'doc' && Array.isArray(e.linkedSymbols) && e.linkedSymbols.length) {
|
|
82
108
|
const refs = e.linkedSymbols.slice(0, 5).map((id) => {
|
|
83
109
|
const s = symbolById.get(id)
|
|
@@ -88,15 +114,28 @@ export function queryMemoryTool(ctx, config) {
|
|
|
88
114
|
}
|
|
89
115
|
}
|
|
90
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
|
+
}
|
|
91
131
|
if (type === 'all' || type === 'experience') {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
const expTop = scoredExp[0].score || 1
|
|
132
|
+
const expBucket = recalled.layers.find((l) => l.layer === 'experience')
|
|
133
|
+
if (expBucket && expBucket.hits.length) {
|
|
95
134
|
lines.push(`## Experience (past problems -> solutions)`)
|
|
96
|
-
for (const { item: e,
|
|
135
|
+
for (const { item: e, rel } of expBucket.hits) {
|
|
97
136
|
const source = e.sourceFile ? ` (source: ${toAbs(root, e.sourceFile)})` : ''
|
|
98
137
|
lines.push(
|
|
99
|
-
`### 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}`,
|
|
100
139
|
)
|
|
101
140
|
}
|
|
102
141
|
}
|
|
@@ -132,9 +171,11 @@ export function queryMemoryTool(ctx, config) {
|
|
|
132
171
|
const hint =
|
|
133
172
|
type === 'experience'
|
|
134
173
|
? 'Note a fix with remember so it can be recalled next time.'
|
|
135
|
-
: type === '
|
|
136
|
-
? '
|
|
137
|
-
:
|
|
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.'
|
|
138
179
|
const tail =
|
|
139
180
|
overview.files === 0
|
|
140
181
|
? '. The store has never been indexed.'
|
package/src/tools/task-tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ProjectMemoryStore } from '../store.js'
|
|
|
4
4
|
import { truncate } from '../util/text.js'
|
|
5
5
|
import { genTaskId, hash8, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
|
|
6
6
|
import { fireReflect } from '../reflection-pipeline.js'
|
|
7
|
+
import { resolveRoute } from '../llm-route.js'
|
|
7
8
|
import { createHash } from 'node:crypto'
|
|
8
9
|
|
|
9
10
|
function sessionIdOf(exec) {
|
|
@@ -137,7 +138,7 @@ export function selectTaskTool(config, host) {
|
|
|
137
138
|
|
|
138
139
|
// 反思钩子:切走旧任务时异步收割(默认关,零成本;失败只记日志)
|
|
139
140
|
if (prevBound && prevBound !== task.id) {
|
|
140
|
-
fireReflect(config, host, root, prevBound, 'switch-away')
|
|
141
|
+
fireReflect(config, host, root, prevBound, 'switch-away', resolveRoute(exec, config))
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
const card = {
|
|
@@ -191,7 +192,7 @@ export function archiveTaskTool(config, host) {
|
|
|
191
192
|
task.updatedAt = new Date().toISOString()
|
|
192
193
|
store.save()
|
|
193
194
|
// 反思钩子:归档即收割最终教训(默认关)
|
|
194
|
-
fireReflect(config, host, root, args.taskId, 'archive')
|
|
195
|
+
fireReflect(config, host, root, args.taskId, 'archive', resolveRoute(exec, config))
|
|
195
196
|
return truncate(JSON.stringify({ success: true, archived: true, hint: '已归档,select_task 可恢复' }), config.maxOutputChars)
|
|
196
197
|
},
|
|
197
198
|
})
|
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/util/search.js
CHANGED
|
@@ -131,6 +131,8 @@ export function weightedFieldText(entry) {
|
|
|
131
131
|
for (let i = 0; i < 5; i++) parts.push(entry.title || '')
|
|
132
132
|
parts.push((entry.keywords || []).join(' '))
|
|
133
133
|
parts.push(entry.summary || '')
|
|
134
|
+
// 结构词项:覆盖整个 chunk 的字面词项,把「只有前 300 字符可检索」补回来(索引期零 LLM)
|
|
135
|
+
parts.push(entry.terms || '')
|
|
134
136
|
parts.push(entry.sourcePath || '')
|
|
135
137
|
return parts.join(' ')
|
|
136
138
|
}
|
package/src/util/text.js
CHANGED
|
@@ -2,4 +2,17 @@ export function truncate(text, maxChars) {
|
|
|
2
2
|
if (typeof text !== 'string') return String(text)
|
|
3
3
|
if (text.length <= maxChars) return text
|
|
4
4
|
return text.slice(0, maxChars) + `\n\n...[truncated at ${maxChars} chars]`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const MAX_SUMMARY = 300
|
|
8
|
+
|
|
9
|
+
/** 注入用短摘要:压平空白,按句子边界截断到 max 字符。纯函数,无 LLM。 */
|
|
10
|
+
export function summarizeText(text, max = MAX_SUMMARY) {
|
|
11
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim()
|
|
12
|
+
if (!flat) return ''
|
|
13
|
+
if (flat.length <= max) return flat
|
|
14
|
+
const clip = max - 1
|
|
15
|
+
const clipped = flat.slice(0, clip)
|
|
16
|
+
const lastBreak = Math.max(clipped.lastIndexOf('。'), clipped.lastIndexOf('.'), clipped.lastIndexOf(';'))
|
|
17
|
+
return lastBreak > clip * 0.4 ? clipped.slice(0, lastBreak + 1) : clipped + '…'
|
|
5
18
|
}
|
package/src/watch.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
import { docEntriesNeedBackfill } from './doc-index.js'
|
|
5
6
|
import { scanSymbols } from './symbols.js'
|
|
6
7
|
import { linkEntries } from './link.js'
|
|
7
8
|
import { ProjectMemoryStore } from './store.js'
|
|
@@ -21,17 +22,34 @@ export class WatchManager {
|
|
|
21
22
|
// 用 load() 的返回值:storeCache 命中时 load() 返回的是缓存实例,忽略返回值会拿到空 store
|
|
22
23
|
const store = new ProjectMemoryStore(memoryRootFor(cwd, this.config.memoryDir)).load()
|
|
23
24
|
if (existsSync(store.dir)) {
|
|
24
|
-
|
|
25
|
-
|
|
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)
|
|
26
36
|
}
|
|
37
|
+
if (dropped) store.save()
|
|
27
38
|
}
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
addRoot(root) {
|
|
42
|
+
// 根目录不存在就拒绝:否则每轮 poll 都会 commit → save → mkdirSync,
|
|
43
|
+
// 把一条历史遗留、已被删除的 watchlist 条目重新「创建」出来。
|
|
44
|
+
if (!existsSync(root)) return false
|
|
45
|
+
// 文件系统根 / 共享临时目录不整体监听(子目录允许)。
|
|
46
|
+
if (isUnwatchableRoot(root)) return false
|
|
31
47
|
if (!this.roots.has(root)) {
|
|
32
48
|
this.roots.set(root, {
|
|
33
49
|
store: new ProjectMemoryStore(memoryRootFor(root, this.config.memoryDir)).load(),
|
|
34
50
|
snapshot: {},
|
|
51
|
+
// rel → 上次已上报的错误信息。坏文件每轮都会重试,逐轮打印会刷屏。
|
|
52
|
+
failures: new Map(),
|
|
35
53
|
})
|
|
36
54
|
return true
|
|
37
55
|
}
|
|
@@ -105,14 +123,16 @@ export class WatchManager {
|
|
|
105
123
|
// 单次读盘:同一 buffer 供哈希与正文使用
|
|
106
124
|
const { hash, buffer } = readFileForIndex(filePath)
|
|
107
125
|
const existing = state.store.fileRecord(rel)
|
|
108
|
-
|
|
126
|
+
// 旧 store 的 doc 条目缺 terms → 一次性回填(即使哈希未变)
|
|
127
|
+
const needsBackfill = isSupportedDoc(ext) && docEntriesNeedBackfill(state.store.entries[rel])
|
|
128
|
+
if (existing && existing.sha256 === hash && !needsBackfill) continue
|
|
109
129
|
|
|
110
130
|
try {
|
|
111
131
|
let entries
|
|
112
132
|
if (isSupportedCode(ext)) {
|
|
113
133
|
entries = scanSymbols(rel, filePath, buffer.toString('utf8'))
|
|
114
134
|
} else {
|
|
115
|
-
entries = await buildDocEntries(
|
|
135
|
+
entries = await buildDocEntries(rel, filePath, {
|
|
116
136
|
chunkChars: this.config.chunkChars,
|
|
117
137
|
maxChunks: this.config.maxChunksPerFile,
|
|
118
138
|
maxFileSizeMb: this.config.maxFileSizeMb,
|
|
@@ -122,15 +142,22 @@ export class WatchManager {
|
|
|
122
142
|
// Dump file - update snapshot so we don't re-hash next poll, but don't index
|
|
123
143
|
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, deleted: true, _sig: sig })
|
|
124
144
|
changed++
|
|
145
|
+
state.failures?.delete(rel)
|
|
125
146
|
continue
|
|
126
147
|
}
|
|
127
148
|
}
|
|
128
149
|
fileUpdates.push({ rel, expectedHash: state.store.fileRecord(rel)?.sha256, hash, size: stats.size, entries, type: isSupportedCode(ext) ? 'code' : 'doc', _sig: sig })
|
|
129
150
|
changed++
|
|
151
|
+
state.failures?.delete(rel)
|
|
130
152
|
} catch (err) {
|
|
131
153
|
// Index failed - rollback snapshot so next poll retries
|
|
132
154
|
delete state.snapshot[rel]
|
|
133
|
-
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
|
+
}
|
|
134
161
|
continue
|
|
135
162
|
}
|
|
136
163
|
}
|