@yolk_vat-y/dsh-project-memory 0.5.5 → 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 +71 -2
- package/README.md +27 -4
- package/README.zh-CN.md +26 -3
- package/package.json +4 -2
- package/src/audit.js +87 -0
- package/src/auto-inject.js +383 -197
- 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 +78 -29
- package/src/lazy.js +15 -46
- package/src/link.js +10 -1
- package/src/ops.js +210 -0
- package/src/parsers/pdfjs-parser.js +2 -4
- package/src/project-profile.js +12 -5
- package/src/readiness.js +305 -37
- 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 +31 -8
- 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/symbols.js
CHANGED
|
@@ -144,18 +144,57 @@ function extractTypeSignature(line) {
|
|
|
144
144
|
return sig
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
function braceDelta(s) {
|
|
148
|
+
let d = 0
|
|
149
|
+
for (const ch of s) {
|
|
150
|
+
if (ch === '{') d++
|
|
151
|
+
else if (ch === '}') d--
|
|
152
152
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
return d
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 读取一条 interface / type 声明,支持 `export`(含 `declare`)与多行形态。
|
|
158
|
+
* 旧实现要求 `^interface`(不吃 export)且 `}` 必须在本行,于是
|
|
159
|
+
* `export interface X {…}`、任何多行 interface、`export type X = …` 在 L1 正则扫描器里
|
|
160
|
+
* 全都产出 0 个符号(TS 增强器可用时才被补回来;没有 typescript 的项目就彻底看不到)。
|
|
161
|
+
* @returns {{kind: 'interface'|'type', name: string, text: string, endIdx: number} | null}
|
|
162
|
+
*/
|
|
163
|
+
function readTypeDeclaration(rawLines, startIdx) {
|
|
164
|
+
const first = rawLines[startIdx].trim()
|
|
165
|
+
const m = first.match(/^(?:export\s+)?(?:declare\s+)?(interface|type)\s+([A-Za-z_$][\w$]*)/)
|
|
166
|
+
if (!m) return null
|
|
167
|
+
const kind = m[1]
|
|
168
|
+
const parts = [first]
|
|
169
|
+
let depth = braceDelta(first)
|
|
170
|
+
let endIdx = startIdx
|
|
171
|
+
const complete = () => (kind === 'interface'
|
|
172
|
+
? depth <= 0 && parts[parts.length - 1].includes('}')
|
|
173
|
+
: depth <= 0 && /[;}]/.test(parts[parts.length - 1]))
|
|
174
|
+
for (let j = startIdx + 1; j < rawLines.length && j - startIdx <= 20 && !complete(); j++) {
|
|
175
|
+
const t = rawLines[j].trim()
|
|
176
|
+
if (!t) {
|
|
177
|
+
if (depth <= 0) break
|
|
178
|
+
parts.push(t)
|
|
179
|
+
endIdx = j
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
// 归零后遇到下一条声明开头就收尾(TS 的 type 别名常不写分号)
|
|
183
|
+
if (depth <= 0 && j > startIdx && /^(?:export\s+)?(?:declare\s+)?(?:interface|type|function|class|const|let|var|import|enum)\b/.test(t)) break
|
|
184
|
+
parts.push(t)
|
|
185
|
+
depth += braceDelta(t)
|
|
186
|
+
endIdx = j
|
|
157
187
|
}
|
|
158
|
-
return ''
|
|
188
|
+
return { kind, name: m[2], text: parts.join(' '), endIdx }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function formatTypeDeclaration({ kind, text }) {
|
|
192
|
+
if (kind === 'interface') {
|
|
193
|
+
const body = text.match(/\{([\s\S]*)\}/)
|
|
194
|
+
return body ? `{ ${body[1].replace(/\s+/g, ' ').trim()} }` : ''
|
|
195
|
+
}
|
|
196
|
+
const eq = text.match(/=\s*([\s\S]+?);?\s*$/)
|
|
197
|
+
return eq ? `= ${eq[1].replace(/\s+/g, ' ').trim()}` : ''
|
|
159
198
|
}
|
|
160
199
|
|
|
161
200
|
function extractOverloads(masked, startIdx) {
|
|
@@ -226,6 +265,7 @@ function scanJsLike(masked, relPath, rawLines) {
|
|
|
226
265
|
const symbols = []
|
|
227
266
|
let prevOpensBlock = false
|
|
228
267
|
for (let i = 0; i < masked.length; i++) {
|
|
268
|
+
const declStart = i // 多行声明会推进 i,符号行号要记声明的**首行**
|
|
229
269
|
const rawText = rawLines[i].trim()
|
|
230
270
|
const maskedText = masked[i].trim()
|
|
231
271
|
if (!rawText) continue
|
|
@@ -233,13 +273,12 @@ function scanJsLike(masked, relPath, rawLines) {
|
|
|
233
273
|
let matched = null
|
|
234
274
|
let joinedText = null
|
|
235
275
|
|
|
236
|
-
//
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
}
|
|
276
|
+
// interface / type(支持 export 与多行;没有 TS 增强器时这是唯一来源)
|
|
277
|
+
const typeDecl = readTypeDeclaration(rawLines, i)
|
|
278
|
+
if (typeDecl) {
|
|
279
|
+
const sig = formatTypeDeclaration(typeDecl)
|
|
280
|
+
if (sig) matched = { name: typeDecl.name, kind: typeDecl.kind, typeSig: sig }
|
|
281
|
+
i = typeDecl.endIdx
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
if (!matched) {
|
|
@@ -294,7 +333,7 @@ function scanJsLike(masked, relPath, rawLines) {
|
|
|
294
333
|
if (overloads.length > 1) matched.overloads = overloads
|
|
295
334
|
}
|
|
296
335
|
|
|
297
|
-
symbols.push(buildSymbol(matched, relPath, rawLines[
|
|
336
|
+
symbols.push(buildSymbol(matched, relPath, rawLines[declStart], declStart + 1))
|
|
298
337
|
}
|
|
299
338
|
|
|
300
339
|
prevOpensBlock = masked[i].trim().endsWith('{')
|
|
@@ -433,7 +472,7 @@ function buildSymbol(matched, relPath, rawLine, lineNo) {
|
|
|
433
472
|
export function scanSymbols(a, b, c) {
|
|
434
473
|
// Backward compatible: old signature (filePath, content) or new (relPath, filePath, content)
|
|
435
474
|
const [relPath, filePath, content] = c === undefined ? [a, a, b] : [a, b, c]
|
|
436
|
-
const ext = relPath.slice(relPath.lastIndexOf('.'))
|
|
475
|
+
const ext = relPath.slice(relPath.lastIndexOf('.')).toLowerCase()
|
|
437
476
|
const lines = content.split(/\r?\n/)
|
|
438
477
|
if (JS_LIKE.has(ext)) return scanJsLike(maskTokens(lines, JS_MASKER), relPath, lines)
|
|
439
478
|
if (PYTHON.has(ext)) return scanPython(maskTokens(lines, PY_MASKER), relPath, lines)
|
package/src/tools/forget.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 forgetTool(config) {
|
|
@@ -24,6 +24,7 @@ export function forgetTool(config) {
|
|
|
24
24
|
},
|
|
25
25
|
async execute(args, exec) {
|
|
26
26
|
const root = resolveIndexRoot(exec, args.root)
|
|
27
|
+
assertIndexRoot(root, args.root || root)
|
|
27
28
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
28
29
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
29
30
|
return store.commit((s) => {
|
package/src/tools/index-doc.js
CHANGED
|
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { assertReadableFile, memoryRootFor, sha256OfFile, storeKey } from '../util/fs.js'
|
|
4
4
|
import { buildDocEntries } from '../doc-pipeline.js'
|
|
5
|
+
import { docEntriesNeedBackfill } from '../doc-index.js'
|
|
5
6
|
import { linkEntries } from '../link.js'
|
|
6
7
|
import { ProjectMemoryStore } from '../store.js'
|
|
7
8
|
import { findProjectRoot } from '../lazy.js'
|
|
@@ -38,7 +39,9 @@ export function indexDocTool(ctx, config) {
|
|
|
38
39
|
const rel = storeKey(path.relative(root, filePath).split(path.sep).join('/'))
|
|
39
40
|
const { hash, size } = await sha256OfFile(filePath)
|
|
40
41
|
const existing = store.fileRecord(rel)
|
|
41
|
-
|
|
42
|
+
// 与 index_repo/watch/lazy 同一条判据:哈希未变但旧条目缺 terms 时仍要重抽一次(一次性回填)。
|
|
43
|
+
// 少了这一步,terms 回填就是"路径相关"的——只有走 index_repo 才生效。
|
|
44
|
+
if (existing && existing.sha256 === hash && !docEntriesNeedBackfill(store.entries[rel])) {
|
|
42
45
|
return `Skipped (unchanged): ${rel}\nAlready indexed with ${(store.entries[rel] || []).length} entry/entries.`
|
|
43
46
|
}
|
|
44
47
|
|
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'
|
|
@@ -35,16 +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
|
-
actions: { type: 'array', items: { type: 'string' } },
|
|
41
|
-
paths: { type: 'array', items: { type: 'string' } },
|
|
42
|
-
scope: { type: 'array', items: { type: 'string' } },
|
|
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 空间里)。' },
|
|
43
65
|
},
|
|
44
66
|
description:
|
|
45
|
-
'authored trigger
|
|
46
|
-
'
|
|
47
|
-
'
|
|
67
|
+
'authored trigger:命中即在**动手前**确定性注入。' +
|
|
68
|
+
'when = 唯一触发面(ops / writes / intents 取或);guard 只能收窄;prevents 是准入条件。' +
|
|
69
|
+
'没有 when 的条目不会自动推送,只出现在记忆目录里供按需拉取。',
|
|
48
70
|
},
|
|
49
71
|
task_id: { type: 'string', description: '目标任务 id(scope 缺省时优先于绑定任务)' },
|
|
50
72
|
files: { type: 'array', items: { type: 'string' }, description: '关联文件(项目相对路径)' },
|
|
@@ -55,6 +77,7 @@ export function lessonTool(config) {
|
|
|
55
77
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
56
78
|
async execute(args, exec) {
|
|
57
79
|
const root = resolveIndexRoot(exec, args.root)
|
|
80
|
+
assertIndexRoot(root, args.root || root)
|
|
58
81
|
const memoryDir = memoryRootFor(root, config.memoryDir)
|
|
59
82
|
const store = new ProjectMemoryStore(memoryDir).load()
|
|
60
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
|
+
}
|