@yolk_vat-y/dsh-project-memory 0.5.8 → 0.5.9

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.
@@ -1,26 +1,45 @@
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, memoryRootFor, relativePath, storeKey, walkDir } from '../util/fs.js'
4
+ import { assertIndexRoot, assertSafeRoot, memoryRootFor, relativePath, scanLimits, storeKey, walkDir } from '../util/fs.js'
5
5
  import { DROP, OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from '../index-pipeline.js'
6
6
  import { ProjectMemoryStore } from '../store.js'
7
7
  import { onFileIndexed, isTypeScriptFile } from '../enhancer.js'
8
8
 
9
- export async function indexRepository(ctx, config, root, { reindex = false } = {}) {
9
+ /**
10
+ * 每批提交的文件数。旧实现把整棵树的 updates 攒到最后一次性 commit:一次全量扫描的
11
+ * 峰值内存 ≈ 整棵树的所有条目(家目录级别直接 OOM)。分批后峰值与树的大小解耦。
12
+ */
13
+ const COMMIT_BATCH = 200
14
+
15
+ export async function indexRepository(ctx, config, root, { reindex = false, allowUnsafe = false } = {}) {
10
16
  // 先校验根目录:缺了它,下面 `new ProjectMemoryStore(...).load()` 的 save() 会把
11
17
  // 不存在的 root 连同 .dsh-project-memory 一起 mkdirSync 出来。
12
18
  assertIndexRoot(root)
19
+ // 再校验安全:家目录 / 系统目录 / Homebrew 前缀整体索引会吃满内存(issue #5)。
20
+ assertSafeRoot(root, { allowUnsafe })
13
21
  const memoryDir = memoryRootFor(root, config.memoryDir)
14
22
  const store = new ProjectMemoryStore(memoryDir).load()
15
23
 
16
24
  const seen = new Set()
17
- const updates = []
18
25
  const failures = []
26
+ // 增强只能在文件的基础条目**落盘之后**排队:TS 增强是「合并进已有条目」,
27
+ // 若先于提交跑,随后 setEntries 会把增强结果整段覆盖掉。
28
+ const tsFiles = []
29
+ let batch = []
19
30
  let indexed = 0
20
31
  let updated = 0
21
32
  let skipped = 0
22
33
 
23
- for (const filePath of walkDir(root)) {
34
+ const flushBatch = () => {
35
+ if (!batch.length) return
36
+ // 中间批次不做 unseen 清理、不重建链接(最后一批统一做)——否则每批都要遍历整个 store。
37
+ commitFileUpdates(store, { updates: batch, link: false })
38
+ batch = []
39
+ }
40
+
41
+ const { files, truncated } = walkDir(root, scanLimits(config))
42
+ for (const filePath of files) {
24
43
  const rel = storeKey(relativePath(root, filePath))
25
44
  seen.add(rel)
26
45
  const kind = fileKind(path.extname(filePath).toLowerCase())
@@ -36,37 +55,50 @@ export async function indexRepository(ctx, config, root, { reindex = false } = {
36
55
  }
37
56
  if (plan.key === OVERSIZE) {
38
57
  // 体积超限的代码文件:旧记录一律清掉,避免检索命中一个已经不索引的文件。
39
- updates.push({ rel, drop: true })
58
+ batch.push({ rel, drop: true })
40
59
  skipped++
60
+ if (batch.length >= COMMIT_BATCH) flushBatch()
41
61
  continue
42
62
  }
43
- updates.push(toFileUpdate(rel, plan, record))
63
+ batch.push(toFileUpdate(rel, plan, record))
44
64
  if (plan.key === DROP) skipped++
45
- else if (plan.type === 'code') updated++
46
- else indexed++
65
+ else if (plan.type === 'code') {
66
+ updated++
67
+ if (isTypeScriptFile(filePath)) tsFiles.push([rel, filePath])
68
+ } else {
69
+ indexed++
70
+ }
71
+ if (batch.length >= COMMIT_BATCH) flushBatch()
47
72
  } catch {
48
73
  failures.push(rel)
49
74
  }
50
75
  }
51
76
 
52
- // 单事务提交:写入 + 清理本轮未见到的旧条目 + 重建链接。
53
- const { removed } = commitFileUpdates(store, { updates, unseen: seen })
77
+ // 收尾:写完最后一批 + 清理本轮未见到的旧条目 + 重建链接。
78
+ // 截断时**不做 unseen 清理**:没扫到的文件不等于被删了。
79
+ const { removed } = commitFileUpdates(store, {
80
+ updates: batch,
81
+ unseen: truncated ? null : seen,
82
+ link: true,
83
+ })
54
84
  const stats = store.stats()
55
85
  let report =
56
86
  `Indexed project: ${root}\n` +
57
87
  `docs indexed: ${indexed}, code symbols updated: ${updated}, unchanged skipped: ${skipped}, removed: ${removed}\n` +
58
88
  `memory store: ${stats.files} files, ${stats.entries} entries, ${stats.experience} experience notes`
89
+ if (truncated) {
90
+ const { maxFiles, maxDepth } = scanLimits(config)
91
+ report +=
92
+ `\nscan truncated at the safety limit (maxFiles=${maxFiles}, maxDepth=${maxDepth}): only part of the tree was indexed.` +
93
+ ' Raise maxScanFiles/maxScanDepth if this project is legitimately that large.'
94
+ }
59
95
  if (failures.length) {
60
96
  report += `\nfailed to index ${failures.length} file(s): ${failures.join(', ')}`
61
97
  }
62
98
 
63
- // Trigger TS enhancement for code files (TS/JS only)
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)
69
- }
99
+ // Trigger TS enhancement for code files (TS/JS only), now that the base entries are on disk.
100
+ for (const [rel, filePath] of tsFiles) {
101
+ onFileIndexed(store, rel, filePath, config, root)
70
102
  }
71
103
 
72
104
  return report
@@ -100,6 +132,7 @@ export function indexRepoTool(ctx, config) {
100
132
  // 透传原始入参:Windows 风格路径在 POSIX 上会被 resolve 成 <cwd>/D:\...,
101
133
  // 报错时要点明这是路径风格问题,而不是“目录被删了”。
102
134
  assertIndexRoot(root, args.root)
135
+ assertSafeRoot(root, { requested: args.root, allowUnsafe: config?.allowUnsafeRoots === true })
103
136
  return indexRepository(ctx, config, root, { reindex: Boolean(args.reindex) })
104
137
  },
105
138
  })
@@ -1,14 +1,11 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
2
+ import { assertIndexRoot, memoryRootFor, resolveSafeIndexRoot } 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'
6
6
  import { ensureGlobalInit } from '../global-seed.js'
7
7
  import { INSIGHT_KINDS, INSIGHT_SCOPES } from '../insight-store.js'
8
-
9
- function sessionIdOf(exec) {
10
- return exec?.agent?.session?.id || exec?.ctx?.session?.id
11
- }
8
+ import { sessionIdOf } from '../util/task-view.js'
12
9
 
13
10
  export function lessonTool(config) {
14
11
  const kindDesc = `insight 语义:lesson(曾踩坑/纠偏, pattern→fix) | decision(权衡选型, choice→reason) | procedure(多步指南, steps) | experience(problem→solution,v0.4 兼容)。默认 lesson。所有 kind 都可带 trigger:命中即在动手前确定性注入。`
@@ -76,7 +73,7 @@ export function lessonTool(config) {
76
73
  },
77
74
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
78
75
  async execute(args, exec) {
79
- const root = resolveIndexRoot(exec, args.root)
76
+ const root = resolveSafeIndexRoot(exec, args.root, config)
80
77
  assertIndexRoot(root, args.root || root)
81
78
  const memoryDir = memoryRootFor(root, config.memoryDir)
82
79
  const store = new ProjectMemoryStore(memoryDir).load()
@@ -1,25 +1,17 @@
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 { memoryRootFor, resolveSafeIndexRoot } 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
7
  import { GlobalStore, cfgInsight, defaultGlobalFile, recordHit } from '../insight-store.js'
8
8
  import { recallItems } from '../recall.js'
9
9
  import { truncate } from '../util/text.js'
10
+ import { stepContent, stepStatus } from '../util/task-view.js'
10
11
  function toAbs(root, rel) {
11
12
  return path.isAbsolute(rel) ? rel : path.join(root, rel)
12
13
  }
13
14
 
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
-
23
15
  export function queryMemoryTool(ctx, config) {
24
16
  return defineTool({
25
17
  name: 'query_memory',
@@ -54,7 +46,7 @@ export function queryMemoryTool(ctx, config) {
54
46
  render: (_args, value) => [{ type: 'text', text: value }],
55
47
  },
56
48
  async execute(args, exec) {
57
- const root = resolveIndexRoot(exec, args.root)
49
+ const root = resolveSafeIndexRoot(exec, args.root, config)
58
50
  // 空查询不是"全部":recallItems 会过滤空串,rankEntriesStreaming 随即返回
59
51
  // entries.slice(0, limit)(任意条目、分数 0),task 分支的 includes('') 更是命中所有任务。
60
52
  if (!String(args.query ?? '').trim()) {
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
2
+ import { assertIndexRoot, memoryRootFor, resolveSafeIndexRoot } from '../util/fs.js'
3
3
  import { ProjectMemoryStore } from '../store.js'
4
4
 
5
5
  export function rememberTool(config) {
@@ -34,7 +34,7 @@ export function rememberTool(config) {
34
34
  render: (_args, value) => [{ type: 'text', text: value }],
35
35
  },
36
36
  async execute(args, exec) {
37
- const root = resolveIndexRoot(exec, args.root)
37
+ const root = resolveSafeIndexRoot(exec, args.root, config)
38
38
  // 写盘前校验根:否则一个拼错的 root 会被 mkdir 成字面量目录(连同 .dsh-project-memory)。
39
39
  assertIndexRoot(root, args.root || root)
40
40
  const memoryDir = memoryRootFor(root, config.memoryDir)
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { memoryRootFor, resolveIndexRoot } from '../util/fs.js'
2
+ import { memoryRootFor, resolveSafeIndexRoot } from '../util/fs.js'
3
3
  import { ProjectMemoryStore, storeOverview } from '../store.js'
4
4
  import { truncate } from '../util/text.js'
5
5
 
@@ -22,7 +22,7 @@ export function statsTool(config) {
22
22
  render: (_args, value) => [{ type: 'text', text: value }],
23
23
  },
24
24
  async execute(args, exec) {
25
- const root = resolveIndexRoot(exec, args.root)
25
+ const root = resolveSafeIndexRoot(exec, args.root, config)
26
26
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
27
27
  const overview = storeOverview(store)
28
28
  const files = Object.entries(store.files).sort((a, b) => String(b[1].indexedAt || '').localeCompare(String(a[1].indexedAt || '')))
@@ -1,29 +1,15 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { assertIndexRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
2
+ import { assertIndexRoot, memoryRootFor, resolveSafeIndexRoot } from '../util/fs.js'
3
3
  import { ProjectMemoryStore } from '../store.js'
4
4
  import { truncate } from '../util/text.js'
5
+ import { sessionIdOf, stepProgress, timeAgo } from '../util/task-view.js'
5
6
  import { genTaskId, hash8, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
6
7
  import { fireReflect } from '../reflection-pipeline.js'
7
8
  import { resolveRoute } from '../llm-route.js'
8
9
  import { createHash } from 'node:crypto'
9
10
 
10
- function sessionIdOf(exec) {
11
- return exec?.agent?.session?.id || exec?.ctx?.session?.id
12
- }
13
-
14
- function timeAgo(iso) {
15
- const diff = Date.now() - new Date(iso).getTime()
16
- const m = Math.floor(diff / 60000)
17
- if (m < 1) return '刚刚'
18
- if (m < 60) return `${m}分钟前`
19
- const h = Math.floor(m / 60)
20
- if (h < 24) return `${h}小时前`
21
- return `${Math.floor(h / 24)}天前`
22
- }
23
-
24
11
  function summaryOf(task) {
25
- const done = (task.steps || []).filter((s) => s.status === 'completed').length
26
- const total = (task.steps || []).length
12
+ const { done, total } = stepProgress(task)
27
13
  const files = (task.files || []).length
28
14
  const mark = task.archived ? ' [归档]' : ''
29
15
  return `### ${task.title}${mark}\n- 进度: ${done}/${total} 完成\n- 文件: ${files} 个\n- 最后活动: ${timeAgo(task.lastActiveAt || task.updatedAt)}`
@@ -39,7 +25,7 @@ export function listTasksTool(config) {
39
25
  },
40
26
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
41
27
  async execute(args, exec) {
42
- const root = resolveIndexRoot(exec, args.root)
28
+ const root = resolveSafeIndexRoot(exec, args.root, config)
43
29
  assertIndexRoot(root, args.root || root)
44
30
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
45
31
  const tasks = store.getTasks()
@@ -66,7 +52,7 @@ export function selectTaskTool(config, host) {
66
52
  },
67
53
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
68
54
  async execute(args, exec) {
69
- const root = resolveIndexRoot(exec, args.root)
55
+ const root = resolveSafeIndexRoot(exec, args.root, config)
70
56
  assertIndexRoot(root, args.root || root)
71
57
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
72
58
  const sid = sessionIdOf(exec)
@@ -185,7 +171,7 @@ export function archiveTaskTool(config, host) {
185
171
  },
186
172
  output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
187
173
  async execute(args, exec) {
188
- const root = resolveIndexRoot(exec, args.root)
174
+ const root = resolveSafeIndexRoot(exec, args.root, config)
189
175
  assertIndexRoot(root, args.root || root)
190
176
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
191
177
  const task = store.getTask(args.taskId)
@@ -1,6 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import path from 'node:path'
3
- import { assertIndexRoot, isUnwatchableRoot, memoryRootFor, resolveIndexRoot } from '../util/fs.js'
3
+ import { assertIndexRoot, assertSafeRoot, isUnsafeRoot, memoryRootFor, sessionMemoryRootOrNull } from '../util/fs.js'
4
4
  import { ProjectMemoryStore } from '../store.js'
5
5
 
6
6
  export function watchRepoTool(watchManager, config) {
@@ -29,23 +29,24 @@ export function watchRepoTool(watchManager, config) {
29
29
  const root = path.resolve(args.root)
30
30
  // 不存在的根不写进 watchlist:watch 每轮会 commit → save → mkdirSync,把它重新造出来。
31
31
  // 根是**文件**时同样要拦:否则 save() 的 mkdirSync 会抛裸 ENOTDIR。
32
+ // 家目录 / 系统目录 / Homebrew 前缀也不监听:整体轮询扫描会吃满内存(issue #5)。
32
33
  if (args.watch !== false) {
33
34
  try {
34
35
  assertIndexRoot(root, args.root)
36
+ assertSafeRoot(root, { requested: args.root, allowUnsafe: config?.allowUnsafeRoots === true })
35
37
  } catch (err) {
36
38
  return `Not watching: ${err.message}`
37
39
  }
38
40
  }
39
- // 文件系统根 / 共享临时目录不整体监听(子目录允许):会把无关程序和测试夹具的临时文件全扫进来
40
- if (args.watch !== false && isUnwatchableRoot(root)) {
41
- return `Refusing to watch ${root}: it is the filesystem root or the shared temp directory. Pass a project subdirectory instead.`
42
- }
43
41
  const memoryDir = memoryRootFor(root, config.memoryDir)
44
- const sessionRoot = resolveIndexRoot(exec)
45
- const sessionMemoryDir = memoryRootFor(sessionRoot, config.memoryDir)
46
- const store = new ProjectMemoryStore(memoryDir).load()
42
+ // 会话记忆根与工具侧同一套解析(标记优先 → 会话 cwd);拿不到就跳过镜像。
43
+ const sessionRoot = sessionMemoryRootOrNull(exec, config)
44
+ const sessionMemoryDir = sessionRoot ? memoryRootFor(sessionRoot, config.memoryDir) : null
47
45
 
48
46
  const mirrorSessionWatchlist = async (present) => {
47
+ // 会话根不可用(家目录/系统目录且无标记)时跳过镜像:那会在不该有项目记忆的目录里
48
+ // 造出 watch.json,而 watch.json 一旦落在那里,下次以该目录为 cwd 启动又会把它读回来。
49
+ if (!sessionRoot || !sessionMemoryDir) return
49
50
  if (path.resolve(sessionMemoryDir) === path.resolve(memoryDir)) return
50
51
  const sessionStore = new ProjectMemoryStore(sessionMemoryDir).load()
51
52
  const removed = sessionStore.removeWatch(root)
@@ -55,11 +56,17 @@ export function watchRepoTool(watchManager, config) {
55
56
 
56
57
  if (args.watch === false) {
57
58
  watchManager.removeRoot(root)
58
- store.commit((s) => s.removeWatch(root))
59
+ // 危险根的 store 一律不读不写:load() 一个历史遗留的超大 store 光读盘就能 OOM
60
+ // (这正是要修的场景),而写它会在家目录里造出 .dsh-project-memory。那份
61
+ // watch.json 现在也没有任何启动路径会读取(restorePersisted 只认会话 cwd),
62
+ // 留着即可;会话侧的镜像条目照常摘掉。
63
+ if (!isUnsafeRoot(root)) {
64
+ new ProjectMemoryStore(memoryDir).load().commit((s) => s.removeWatch(root))
65
+ }
59
66
  await mirrorSessionWatchlist(false)
60
67
  return `Stopped watching: ${root}`
61
68
  }
62
- store.commit((s) => s.addWatch(root))
69
+ new ProjectMemoryStore(memoryDir).load().commit((s) => s.addWatch(root))
63
70
  await mirrorSessionWatchlist(true)
64
71
  watchManager.addRoot(root)
65
72
  watchManager.start(config.watchInterval * 1000)