@yolk_vat-y/dsh-project-memory 0.5.4 → 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.
@@ -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 { rankEntriesMergedScored, rankExperienceScored, rankEntriesStreaming } from '../util/search.js'
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
- 'plus experience notes (problem -> solution, saved via remember). Every hit returns its source path and line so ' +
20
- 'you can verify by reading the real file. Docs are cross-linked to the code symbols they mention. ' +
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 (title/steps/files).',
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
- const idf = store.getIdfCache()
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 (type === 'all' || type === 'doc' || type === 'symbol') {
64
- const pool = type === 'all' ? store.allEntries() : store.allEntries().filter((e) => e.type === type)
65
- const scored = rankEntriesStreaming(pool, queries, idf, limit)
66
- if (scored.length) {
67
- const top = scored[0].score || 1
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 { entry: e, score } of scored) {
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((score / top) * 100)
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 scoredExp = rankExperienceScored(store.experience, queries, limit)
96
- if (scoredExp.length) {
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, score } of scoredExp) {
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((score / expTop) * 100)}, id: ${e.id})\n- solution: ${e.solution}${source}\n- updated: ${e.updatedAt}`,
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 === 'all'
139
- ? 'Index it first with index_repo / index_doc, or note a fix with remember.'
140
- : 'Index it first with index_repo / index_doc.'
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.'
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import path from 'node:path'
3
- import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
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
- for (const root of store.watchlist) {
26
- if (typeof root === 'string' && root) this.addRoot(root)
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(`[dsh-project-memory] re-index failed for ${rel}: ${err.message}`)
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
  }