@yolk_vat-y/dsh-project-memory 0.5.7 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +139 -193
  3. package/README.zh-CN.md +136 -192
  4. package/client/client.js +458 -107
  5. package/client/client.js.map +1 -1
  6. package/cordis.patch.yml +7 -1
  7. package/package.json +4 -6
  8. package/scripts/bench-synthetic.mjs +3 -3
  9. package/scripts/bench.mjs +1 -1
  10. package/src/audit.js +63 -0
  11. package/src/auto-inject.js +177 -34
  12. package/src/client/MemoryView.tsx +12 -4
  13. package/src/client/TaskCommandNode.tsx +1 -1
  14. package/src/client/TaskComponents.tsx +3 -2
  15. package/src/client/TaskPanel.tsx +14 -14
  16. package/src/client/client.ts +93 -21
  17. package/src/client/css-modules.d.ts +12 -0
  18. package/src/client/icons.ts +63 -0
  19. package/src/client/locales.ts +11 -0
  20. package/src/client/session-id.js +88 -0
  21. package/src/client/slash.ts +184 -0
  22. package/src/commands/insight-actions.js +15 -3
  23. package/src/commands/task-actions.js +19 -14
  24. package/src/commands/tasks.js +4 -22
  25. package/src/commands/workflow.js +54 -0
  26. package/src/index.js +51 -9
  27. package/src/insight-store.js +39 -0
  28. package/src/lazy.js +22 -80
  29. package/src/reflection-pipeline.js +2 -1
  30. package/src/setup/taskbridge.js +19 -12
  31. package/src/tools/forget.js +2 -2
  32. package/src/tools/index-doc.js +20 -5
  33. package/src/tools/index-repo.js +50 -17
  34. package/src/tools/lesson-tools.js +3 -6
  35. package/src/tools/query-memory.js +15 -12
  36. package/src/tools/remember.js +2 -2
  37. package/src/tools/stats.js +2 -2
  38. package/src/tools/task-tools.js +6 -20
  39. package/src/tools/watch-repo.js +17 -10
  40. package/src/util/fs.js +351 -16
  41. package/src/util/task-view.js +66 -0
  42. package/src/util/text.js +14 -0
  43. package/src/watch.js +46 -7
@@ -2,24 +2,22 @@
2
2
  // switch <taskId> — 绑定当前会话到该任务(自动解归档),等价 select_task(taskId)
3
3
  // archive <taskId> — 归档该任务
4
4
  // 返回值与 /tasks 同一快照文本(含 JSON 载荷),面板解析后即时刷新,无需再跑一次 /tasks。
5
- import { memoryRootFor } from '../util/fs.js'
6
- import { ProjectMemoryStore } from '../store.js'
7
- import { projectRootFor, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
5
+ import { taskStoreFor, projectRootFor, NO_PROJECT_ROOT_NOTE, adoptStepsToSession, shouldAdoptToHost } from '../setup/taskbridge.js'
8
6
  import { renderTaskSnapshot, buildTaskPayload } from './tasks.js'
9
7
  import { fireReflect } from '../reflection-pipeline.js'
10
8
  import { resolveRoute } from '../llm-route.js'
11
9
  import { fencedJson, invocationContext } from './invocation.js'
10
+ import { stepContent, stepProgress, stepStatus } from '../util/task-view.js'
12
11
 
13
12
  function describeTask(t) {
14
- const done = (t.steps || []).filter((s) => s.status === 'completed').length
15
- const total = (t.steps || []).length
13
+ const { done, total } = stepProgress(t)
16
14
  return `「${t.title}」(步骤 ${done}/${total})`
17
15
  }
18
16
 
19
17
  /**
20
18
  * raw 去掉前 n 个空白分隔 token 后的**原始剩余文本**(保留内部空白与引号)。
21
19
  * 旧写法 `raw.split(/\s+/).slice(n).join(' ')` 会把标题/JSON 里的连续空格压成一个,
22
- * `/task rename <id> "a b"` 于是改名成 "a b"。
20
+ * `/tasks rename <id> "a b"` 于是改名成 "a b"。
23
21
  */
24
22
  function restAfter(raw, n) {
25
23
  let i = 0
@@ -43,10 +41,21 @@ function withTaskSnapshot(config, cwd, sid, store, note) {
43
41
  return { kind: 'success', text: fencedJson(human, payload) }
44
42
  }
45
43
 
44
+ /**
45
+ * 任务动作处理器。
46
+ *
47
+ * ⚠️ 这里返回的定义**不再作为宿主命令注册**:合并后唯一的用户命令是 `/tasks`
48
+ * (见 workflow.js),本处理器是它的子动词分支(`/tasks switch|archive|unbind|rename|todos …`),
49
+ * 由卡片按钮经 remote.commands.execute 驱动。`name` / `description` / `input` 因此只剩说明意义,
50
+ * 真正被使用的是 `handler`——它读 `invocation.rawInput` 自行分词,合并前后一字未变。
51
+ * @param {object} config - 插件配置
52
+ * @param {object} ctx - 宿主上下文
53
+ * @returns {{name: string, description: string, input: object, handler: Function}}
54
+ */
46
55
  export function taskCommandDefinition(config, ctx) {
47
56
  return {
48
57
  name: 'task',
49
- description: '任务面板动作:/task switch <任务id> 切换绑定,/task archive <任务id> 归档,/task unbind 取消当前任务绑定',
58
+ description: '任务面板动作:/tasks switch <任务id> 切换绑定,/tasks archive <任务id> 归档,/tasks unbind 取消当前任务绑定',
50
59
  input: { hint: 'switch|archive <任务id> | unbind' },
51
60
  handler: (invocation) => {
52
61
  try {
@@ -66,8 +75,8 @@ export function taskCommandDefinition(config, ctx) {
66
75
  if (!['unbind', 'todos', 'rename'].includes(verb) && (!taskId || rest.length)) {
67
76
  return { kind: 'error', text: '[task] 用法: /task switch|archive <任务id> | unbind' }
68
77
  }
69
- const root = projectRootFor(cwd)
70
- const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
78
+ const { root, store } = taskStoreFor(cwd, config)
79
+ if (!store) return { kind: 'error', text: NO_PROJECT_ROOT_NOTE }
71
80
 
72
81
  if (verb === 'unbind') {
73
82
  if (sid) store.removeBinding(sid)
@@ -116,11 +125,7 @@ export function taskCommandDefinition(config, ctx) {
116
125
  return { kind: 'error', text: '[task] todos 需要 JSON 数组参数(由面板自动生成)' }
117
126
  }
118
127
  const norm = todos
119
- .map((s) => {
120
- const content = String(s?.content ?? s?.text ?? '').trim()
121
- const status = ['pending', 'in_progress', 'completed'].includes(s?.status) ? s.status : 'pending'
122
- return { content, status }
123
- })
128
+ .map((s) => ({ content: String(stepContent(s)).trim(), status: stepStatus(s) }))
124
129
  .filter((s) => s.content.length > 0)
125
130
  const boundId = sid ? store.getBoundTaskId(sid) : null
126
131
  if (!boundId) {
@@ -3,27 +3,9 @@
3
3
  // renderTaskSnapshot / buildTaskPayload 导出供 task-actions.js 复用(面板动作后返回同一快照)。
4
4
  import { memoryRootFor } from '../util/fs.js'
5
5
  import { ProjectMemoryStore } from '../store.js'
6
- import { projectRootFor } from '../setup/taskbridge.js'
6
+ import { NO_PROJECT_ROOT_NOTE, projectRootFor } from '../setup/taskbridge.js'
7
7
  import { fencedJson, invocationContext } from './invocation.js'
8
-
9
- function timeAgo(iso) {
10
- const diff = Date.now() - new Date(iso).getTime()
11
- const m = Math.floor(diff / 60000)
12
- if (m < 1) return '刚刚'
13
- if (m < 60) return `${m}分钟前`
14
- const h = Math.floor(m / 60)
15
- if (h < 24) return `${h}小时前`
16
- return `${Math.floor(h / 24)}天前`
17
- }
18
-
19
- /** 步骤在历史数据里可能是字符串,也可能是 {content|text, status}:统一取文本与状态。 */
20
- function stepContent(s) {
21
- if (typeof s === 'string') return s
22
- return s?.content ?? s?.text ?? ''
23
- }
24
- function stepStatus(s) {
25
- return typeof s === 'string' ? 'pending' : (s?.status || 'pending')
26
- }
8
+ import { stepContent, stepProgress, stepStatus, timeAgo } from '../util/task-view.js'
27
9
 
28
10
  export function buildTaskPayload(tasks, boundId, archived) {
29
11
  return JSON.stringify({
@@ -48,6 +30,7 @@ export function buildTaskPayload(tasks, boundId, archived) {
48
30
  */
49
31
  export function renderTaskSnapshot(config, cwd, sid) {
50
32
  const root = projectRootFor(cwd)
33
+ if (!root) return { kind: 'error', text: NO_PROJECT_ROOT_NOTE }
51
34
  const store = new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load()
52
35
  const tasks = store.getTasks()
53
36
  const active = tasks.filter((t) => !t.archived)
@@ -62,8 +45,7 @@ export function renderTaskSnapshot(config, cwd, sid) {
62
45
  }
63
46
  const lines = [`项目 ${root}`, `任务: ${active.length} 套${archived ? `(归档 ${archived})` : ''}`, '']
64
47
  for (const t of active) {
65
- const done = (t.steps || []).filter((s) => stepStatus(s) === 'completed').length
66
- const total = (t.steps || []).length
48
+ const { done, total } = stepProgress(t)
67
49
  const progress = total ? `${done}/${total}` : '无步骤'
68
50
  const inProgress = (t.steps || []).find((s) => stepStatus(s) === 'in_progress')
69
51
  const step = inProgress ? ` · 当前: ${stepContent(inProgress)}` : ''
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 单一用户命令 `/tasks`:把合并前的三条宿主命令(`/tasks`、`/task`、`/insight`)收成一条,
3
+ * 其余动作作为「子动词」由面板按钮经 `remote.commands.execute` 直接驱动。
4
+ *
5
+ * 为什么合并:宿主命令**只要注册就会出现在 `/` 菜单的「指令」组里** —— `commands.list()`
6
+ * 与 `commands.execute()` 读同一个视图,`CommandDefinition` 也没有 hidden 字段,插件无法隐藏
7
+ * 自己的宿主命令(见 client/slash.ts 的长注释)。三条命令 = 三行去不掉的原始行;而用户可见
8
+ * 入口已经收进自建的「工作流」组,于是同一批能力在菜单里出现两遍。合并后只剩一行。
9
+ *
10
+ * 为什么**不**声明 `input`:声明了就是 leadingInput —— 手敲 `/tasks` 回车时,
11
+ * `ui-commands.matchEnter` 对带 input 的命令一律返回 claim(回填 `/tasks ` 并要求再按一次
12
+ * 回车)。不声明时裸 `/tasks` 回车立刻执行,与合并前完全一致。
13
+ * 代价:手敲 `/tasks switch x` 这类带参数的行不再被认作命令(会作为普通消息发给模型)。
14
+ * 这些动作的入口本来就是面板按钮,不需要用户手敲。
15
+ *
16
+ * 子动词约定:
17
+ * /tasks → 任务清单快照(等价于合并前的 /tasks)
18
+ * /tasks switch|archive|unbind|rename|todos … → 任务动作(等价于 /task …)
19
+ * /tasks insight list|confirm|promote|… … → 记忆动作(等价于 /insight …)
20
+ */
21
+ import { tasksCommandDefinition } from './tasks.js'
22
+ import { taskCommandDefinition } from './task-actions.js'
23
+ import { insightCommandDefinition } from './insight-actions.js'
24
+
25
+ /** 记忆动作的分派前缀;也是 `/tasks` 之后第一个 token。 */
26
+ const INSIGHT_VERB = 'insight'
27
+
28
+ /**
29
+ * 造出唯一的用户命令定义。
30
+ * @param {object} config - 插件配置
31
+ * @param {object} ctx - 宿主上下文
32
+ * @returns {{name: string, description: string, handler: Function}}
33
+ */
34
+ export function workflowCommandDefinition(config, ctx) {
35
+ const snapshot = tasksCommandDefinition(config, ctx)
36
+ const taskAction = taskCommandDefinition(config, ctx)
37
+ const insightAction = insightCommandDefinition(config, ctx)
38
+ return {
39
+ name: 'tasks',
40
+ description: '工作流面板:任务清单与项目经验(切换/归档/审核等动作由面板按钮驱动)',
41
+ handler: (invocation) => {
42
+ const raw = (invocation?.rawInput || '').trim()
43
+ const verb = raw === '' ? '' : raw.split(/\s+/)[0]
44
+ // 裸 /tasks:任务清单快照(保持合并前的行为与文案)
45
+ if (verb === '') return snapshot.handler(invocation)
46
+ // /tasks insight …:把前缀剥掉后原样交给记忆动作处理器(它自己 trim + 分词)
47
+ if (verb === INSIGHT_VERB) {
48
+ return insightAction.handler({ ...invocation, rawInput: raw.slice(INSIGHT_VERB.length) })
49
+ }
50
+ // 其余一律当任务动作(switch / archive / unbind / rename / todos)
51
+ return taskAction.handler(invocation)
52
+ },
53
+ }
54
+ }
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import Schema from '@deepseek-ai/schemastery'
2
+ import { DEFAULT_SCAN_DEPTH, DEFAULT_SCAN_FILES, isUnsafeRoot } from './util/fs.js'
2
3
  import { indexDocTool } from './tools/index-doc.js'
3
4
  import { indexRepoTool, indexRepository } from './tools/index-repo.js'
4
5
  import { queryMemoryTool } from './tools/query-memory.js'
@@ -14,9 +15,7 @@ import { listTasksTool, selectTaskTool, archiveTaskTool, showTaskPanelTool } fro
14
15
  import { lessonTool } from './tools/lesson-tools.js'
15
16
  import { installAutoInject } from './auto-inject.js'
16
17
  import { rememberRoute } from './llm-route.js'
17
- import { tasksCommandDefinition } from './commands/tasks.js'
18
- import { taskCommandDefinition } from './commands/task-actions.js'
19
- import { insightCommandDefinition } from './commands/insight-actions.js'
18
+ import { workflowCommandDefinition } from './commands/workflow.js'
20
19
 
21
20
  export const name = 'dsh-project-memory'
22
21
  export const inject = ['llm', 'tools']
@@ -42,6 +41,15 @@ export const Config = Schema.object({
42
41
  autoIndexOnFirstUse: Schema.boolean().default(false),
43
42
  watch: Schema.boolean().default(true),
44
43
  watchInterval: Schema.number().default(15),
44
+ // 危险根护栏(issue #5):家目录 / 文件系统根 / 系统目录 / 包管理器前缀(/opt/homebrew …)
45
+ // 整体扫描会吃满内存,默认一律拒绝。**自动路径永不越权**——懒索引、会话审计、任务桥在
46
+ // 这类目录里始终零副作用;这个开关只放开**显式**工具调用(index_repo / watch_repo /
47
+ // remember 等带 root 的调用)。
48
+ allowUnsafeRoots: Schema.boolean().default(false),
49
+ // 单次扫描上限。超过即截断(并跳过"删除本轮未见到条目"的清理,避免把没扫到的文件误删)。
50
+ // 显式设 0 表示不限制——自担风险。
51
+ maxScanFiles: Schema.number().default(DEFAULT_SCAN_FILES),
52
+ maxScanDepth: Schema.number().default(DEFAULT_SCAN_DEPTH),
45
53
  tsPath: Schema.string(),
46
54
  enableTypeScript: Schema.boolean().default(true),
47
55
  tasklist: Schema.object({
@@ -77,6 +85,9 @@ export const Config = Schema.object({
77
85
  signalMinRatio: Schema.number().default(0.5),
78
86
  // resident 任务卡最多显示几个「编辑中」文件
79
87
  editedMax: Schema.number().default(3),
88
+ // 记忆根是"推定"的(会话工作目录,且该目录没有项目标记)时,首次注入前通告模型
89
+ // 根在哪、怎么改。状态声明,只发一次、不占条目额度。
90
+ rootNotice: Schema.boolean().default(true),
80
91
  // 模型自己维护清单且之后没有新人类消息时,不把任务卡回声给模型
81
92
  skipEchoSelfTodo: Schema.boolean().default(true),
82
93
  // ⚠️ 旧兼容闸门,**刻意不给默认值**:cfgEngine 以「relevanceMin 是否为 number」选分支,
@@ -90,6 +101,24 @@ export const Config = Schema.object({
90
101
  // 同一条 insight 在本会话里重复注入的冷却(pre-step 步数)。0(默认)= 正文没变就不再注入:
91
102
  // 注入消息留在会话历史里(宿主只追加不压缩),整块重发只是重复占位。>0 用于外部裁剪历史的场景。
92
103
  reinjectItemsAfter: Schema.number().default(0),
104
+ // --- 准入旋钮(0.5.8 起补声明)---
105
+ // 下面这些键自 S2/S4 起就在 cfgEngine / cfgAudit 里生效、README 也一直写着,但**从未**在
106
+ // Schema 里声明过:走 cordis.patch.yml 配它们会被宿主按「not a declared property」拒掉,
107
+ // 等于文档里的旋钮是假的。默认值以 cfgEngine 的兜底值为准(那里是权威,这里只负责暴露)。
108
+ gateCooldownSteps: Schema.number().default(2),
109
+ maxItemsPerSession: Schema.number().default(12),
110
+ maxItemCharsPerSession: Schema.number().default(4000),
111
+ hintMinCoverage: Schema.number().default(0.45),
112
+ hintMinMatched: Schema.number().default(2),
113
+ hintMinSupport: Schema.number().default(0.15),
114
+ legacyScope: Schema.union(['filter', 'ignore']).default('filter'),
115
+ auditLog: Schema.boolean().default(true),
116
+ auditMaxBytes: Schema.number().default(262144),
117
+ // 影子记录(admission-shadow.jsonl):**每步**一行,含全部候选的判据特征与场景。
118
+ // 主审计只在真的注入时写,静默步零痕迹 → 无法离线重放"换个阈值会怎样",也攒不出样本。
119
+ // 只写盘、不进 prompt、不花 token,所以默认开。
120
+ shadowLog: Schema.boolean().default(true),
121
+ shadowMaxBytes: Schema.number().default(2097152),
93
122
  }).default({}),
94
123
  })
95
124
 
@@ -123,15 +152,15 @@ export function apply(ctx, config) {
123
152
  ctx.tools.register(archiveTaskTool(config, { llm: ctx.llm, ctx }))
124
153
  ctx.tools.register(showTaskPanelTool(config))
125
154
 
126
- // /tasks、/task、/insight 用户命令(宿主 commands 服务存在时注册,feature-detect 降级)
155
+ // /tasks:唯一的用户命令(合并自原 /tasks、/task、/insight —— 宿主命令只要注册就会
156
+ // 出现在 `/` 菜单的「指令」组里且无法隐藏,三条命令就是三行去不掉的原始行)。
157
+ // 切换/归档/审核等动作作为子动词,由面板按钮经 remote.commands.execute 驱动。
127
158
  try {
128
159
  ctx.inject(['commands'], (commandsCtx) => {
129
- commandsCtx.commands.register(tasksCommandDefinition(config, ctx))
130
- commandsCtx.commands.register(taskCommandDefinition(config, ctx))
131
- commandsCtx.commands.register(insightCommandDefinition(config, ctx))
160
+ commandsCtx.commands.register(workflowCommandDefinition(config, ctx))
132
161
  })
133
162
  } catch (err) {
134
- console.error(`[dsh-project-memory] /tasks,/task,/insight registration skipped: ${err.message}`)
163
+ console.error(`[dsh-project-memory] /tasks registration skipped: ${err.message}`)
135
164
  }
136
165
 
137
166
  ctx.tools.register(indexDocTool(ctx, config))
@@ -153,9 +182,22 @@ export function apply(ctx, config) {
153
182
  let cancelled = false
154
183
  const run = async () => {
155
184
  const root = process.cwd()
185
+ // 全量索引 cwd 是显式开启的行为,但"cwd 是家目录"几乎总是误用:拒绝并说明,
186
+ // 而不是替用户把整棵树扫一遍(旧实现直接 indexRepository(ctx, config, cwd),
187
+ // 没有任何护栏——这正是 issue #5 里最直接的 OOM 入口)。
188
+ if (isUnsafeRoot(root) && config.allowUnsafeRoots !== true) {
189
+ if (!cancelled) {
190
+ console.error(
191
+ `[dsh-project-memory] autoIndexOnFirstUse skipped: cwd ${root} is not a project root ` +
192
+ '(home / system / package-manager prefix). Start dsh inside a project directory, ' +
193
+ 'or set allowUnsafeRoots: true to override.',
194
+ )
195
+ }
196
+ return
197
+ }
156
198
  try {
157
199
  watchManager.addRoot(root)
158
- const report = await indexRepository(ctx, config, root)
200
+ const report = await indexRepository(ctx, config, root, { allowUnsafe: config.allowUnsafeRoots === true })
159
201
  if (!cancelled) console.log(`[dsh-project-memory] ${report}`)
160
202
  } catch (err) {
161
203
  if (!cancelled) console.error(`[dsh-project-memory] auto-index failed for ${root}: ${err.message}`)
@@ -180,6 +180,45 @@ export function mergeInto(existing, base, cfg, nowIso) {
180
180
  return existing
181
181
  }
182
182
 
183
+ /**
184
+ * 使用记账:条目被**真正用到**时更新活跃度——注入进了上下文,或被 `query_memory` 命中。
185
+ *
186
+ * 为什么必须有:`applyDecay` / `pruneItems` 判活跃度只看 `lastHitAt || updatedAt || createdAt`,
187
+ * 而 `lastHitAt` 此前只由 merge/reinforce 写(= 模型又写了一条相近的知识)。于是一条天天被注入、
188
+ * 但从没人重写它的教训,`decayDays`(默认 90)之后会被自动归档、再也不会被推送——**用得最多的
189
+ * 反而等于没人用过**。这是行为缺陷,不是调优问题。
190
+ *
191
+ * 记账**不参与任何注入判据**:它只影响活跃度/衰减,以及离线训练样本的标签。
192
+ * 语义是"曝光次数"而非"被采纳次数"——更强的信号(模型是否真的照着做了)需要另外的回路。
193
+ * 只记非归档条目(归档件本来就召回不到)。返回实际加一的条数,调用方据此决定是否落盘。
194
+ * @returns {number} 被加一的条目数
195
+ */
196
+ export function recordHit({ store, globalStore, ids, nowIso } = {}) {
197
+ const want = new Set((ids || []).filter(Boolean))
198
+ if (!want.size) return 0
199
+ const now = nowIso || new Date().toISOString()
200
+ let n = 0
201
+ const touch = (items) => {
202
+ let c = 0
203
+ for (const it of items || []) {
204
+ if (!it || it.archived || !want.has(it.id)) continue
205
+ it.hitCount = num(it.hitCount, 0) + 1
206
+ it.lastHitAt = now
207
+ c++
208
+ }
209
+ n += c
210
+ return c
211
+ }
212
+ if (store && typeof store.insightItems === 'function') {
213
+ const items = store.insightItems()
214
+ if (touch(items)) store.replaceInsightItems(items) // 标脏;落盘由调用方决定
215
+ }
216
+ if (globalStore && typeof globalStore.items === 'function') {
217
+ if (touch(globalStore.items())) globalStore.markDirty()
218
+ }
219
+ return n
220
+ }
221
+
183
222
  export function reinforceOnly(existing, base, nowIso) {
184
223
  const now = nowIso || new Date().toISOString()
185
224
  existing.sourceTaskIds = unionStrings(existing.sourceTaskIds, base.sourceTaskIds)
package/src/lazy.js CHANGED
@@ -1,84 +1,21 @@
1
1
  import path from 'node:path'
2
- import { tmpdir } from 'node:os'
3
- import { existsSync, readdirSync, statSync } from 'node:fs'
4
- import { memoryRootFor, relativePath, storeKey } from './util/fs.js'
2
+ import { statSync } from 'node:fs'
3
+ import { findProjectRoot, isUnsafeRoot, memoryRootFor, relativePath, storeKey } from './util/fs.js'
5
4
  import { DROP, OVERSIZE, UNCHANGED, commitFileUpdates, fileKind, planFileIndex, toFileUpdate } from './index-pipeline.js'
6
5
  import { ProjectMemoryStore } from './store.js'
7
6
  import { onFileObserved } from './enhancer.js'
8
7
 
9
- const STRONG_MARKERS = ['.git', '.hg', '.svn']
8
+ // 根推导策略已收敛到 util/fs.js(唯一实现)。这里重新导出,保持既有 import 路径可用。
9
+ export { findProjectRoot }
10
10
 
11
- const WEAK_MARKERS = [
12
- '.dsh-project-memory',
13
- 'package.json',
14
- 'go.mod',
15
- 'Cargo.toml',
16
- 'pyproject.toml',
17
- 'requirements.txt',
18
- 'pom.xml',
19
- ]
20
-
21
- const SOURCE_DIR_NAMES = new Set([
22
- 'src', 'app', 'lib', 'libs', 'tools', 'include', 'core', 'modules',
23
- 'scripts', 'components', 'assets', 'utils', 'shared', 'common', 'server', 'client',
24
- ])
25
-
26
- function looksLikeProjectRoot(dir) {
27
- let hasReadmeFile = false
28
- let sourceDirs = 0
29
- try {
30
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
31
- const name = entry.name.toLowerCase()
32
- if (entry.isDirectory()) {
33
- if (SOURCE_DIR_NAMES.has(name)) sourceDirs++
34
- } else if (name.startsWith('readme')) {
35
- hasReadmeFile = true
36
- }
37
- }
38
- } catch {
39
- return false
40
- }
41
- return sourceDirs >= 2 || (hasReadmeFile && sourceDirs >= 1)
42
- }
43
-
44
- function sameDir(a, b) {
45
- return storeKey(path.resolve(a)) === storeKey(path.resolve(b))
46
- }
47
-
48
- export function findProjectRoot(filePath, ceiling = path.resolve(tmpdir())) {
49
- let dir = path.dirname(filePath)
50
- for (;;) {
51
- if (!sameDir(dir, ceiling)) {
52
- for (const marker of STRONG_MARKERS) {
53
- if (existsSync(path.join(dir, marker))) return dir
54
- }
55
- }
56
- const parent = path.dirname(dir)
57
- if (parent === dir || sameDir(parent, ceiling)) break
58
- dir = parent
59
- }
60
-
61
- dir = path.dirname(filePath)
62
- let best = null
63
- for (;;) {
64
- if (!sameDir(dir, ceiling)) {
65
- for (const marker of WEAK_MARKERS) {
66
- if (existsSync(path.join(dir, marker))) return dir
67
- }
68
- if (!best && looksLikeProjectRoot(dir)) best = dir
69
- }
70
- const parent = path.dirname(dir)
71
- if (parent === dir || sameDir(parent, ceiling)) break
72
- dir = parent
73
- }
74
- return best || path.dirname(filePath)
75
- }
76
-
77
- export async function indexFile(ctx, config, filePath, watchManager = null) {
11
+ export async function indexFile(ctx, config, filePath, watchManager = null, sessionRoot = null) {
78
12
  const kind = fileKind(path.extname(filePath).toLowerCase())
79
13
  if (!kind) return false
80
- const root = findProjectRoot(filePath)
81
- if (!root) return false
14
+ // 根来源优先级:显式 watch 过的根 → 项目标记 → 会话工作目录(安全时)。
15
+ // 显式登记过的根优先,是为了让"没有标记但被 watch_repo 指定过"的项目也能持续刷新。
16
+ const extraRoots = watchManager ? [...watchManager.roots.keys()] : []
17
+ const root = findProjectRoot(filePath, { extraRoots, sessionRoot })
18
+ if (!root || isUnsafeRoot(root)) return false
82
19
 
83
20
  const memoryDir = memoryRootFor(root, config.memoryDir)
84
21
  const store = new ProjectMemoryStore(memoryDir).load()
@@ -95,8 +32,9 @@ export async function indexFile(ctx, config, filePath, watchManager = null) {
95
32
  }
96
33
  if (plan.key === UNCHANGED || plan.key === OVERSIZE) return false
97
34
 
98
- if (watchManager) {
99
- watchManager.addRoot(root)
35
+ if (watchManager && watchManager.addRoot(root)) {
36
+ // 只有真正新登记的根才持久化:addRoot 拒绝的根(不安全/不存在)不再被无条件
37
+ // addWatch 记进 watch.json —— 那样会造出一个每次启动都复活的"僵尸根"。
100
38
  store.addWatch(root)
101
39
  }
102
40
 
@@ -122,28 +60,32 @@ export function setupLazyIndexing(ctx, config, watchManager = null) {
122
60
  const flush = async () => {
123
61
  timer = null
124
62
  const batch = codeFirst([...pending.keys()])
63
+ const roots = new Map(pending)
125
64
  pending.clear()
126
65
  for (const filePath of batch) {
127
66
  try {
128
- await indexFile(ctx, config, filePath, watchManager)
67
+ await indexFile(ctx, config, filePath, watchManager, roots.get(filePath) || null)
129
68
  } catch (err) {
130
69
  console.error(`[dsh-project-memory] lazy index failed for ${filePath}: ${err.message}`)
131
70
  }
132
71
  }
133
72
  }
134
73
 
135
- const queue = (filePath) => {
136
- pending.set(filePath, true)
74
+ const queue = (filePath, sessionRoot) => {
75
+ pending.set(filePath, sessionRoot || null)
137
76
  if (!timer) {
138
77
  timer = setTimeout(flush, 300)
139
78
  if (timer.unref) timer.unref()
140
79
  }
141
80
  }
142
81
 
143
- ctx.on('fs/observed', (target, observation) => {
82
+ // 第三个参数是宿主工具执行上下文(tool-fs / str-replace-editor 都以 `exec` 发射),
83
+ // 取它的 `agent.session.header.cwd` 作为**本次读取所在会话**的工作目录——比
84
+ // process.cwd() 精确(web profile 下同一进程可能有多个会话/项目)。
85
+ ctx.on('fs/observed', (target, observation, actor) => {
144
86
  if (!target || !observation || observation.kind !== 'present') return
145
87
  if (typeof target.displayPath !== 'string' || !target.displayPath) return
146
- queue(target.displayPath)
88
+ queue(target.displayPath, actor?.agent?.session?.header?.cwd)
147
89
  })
148
90
 
149
91
  ctx.effect(() => () => {
@@ -11,6 +11,7 @@ import { memoryRootFor } from './util/fs.js'
11
11
  import { cfgInsight, saveInsight, GlobalStore, defaultGlobalFile } from './insight-store.js'
12
12
  import { chatText, parseStructuredJson } from './llm.js'
13
13
  import { noteDegraded } from './llm-route.js'
14
+ import { stepContent } from './util/task-view.js'
14
15
 
15
16
  export const REFLECT_SYSTEM =
16
17
  'You distill task work into durable lessons and decisions for project memory. ' +
@@ -23,7 +24,7 @@ export const REFLECT_SYSTEM =
23
24
  export function taskReflectionSnapshot(task) {
24
25
  return {
25
26
  title: task ? task.title : '',
26
- steps: ((task && task.steps) || []).map((s) => (typeof s === 'string' ? s : s.content || s.text || '')).filter(Boolean).join('\n'),
27
+ steps: ((task && task.steps) || []).map((s) => stepContent(s)).filter(Boolean).join('\n'),
27
28
  files: ((task && task.files) || []).join('\n'),
28
29
  }
29
30
  }
@@ -3,9 +3,9 @@
3
3
  // tool/call data.arguments 为 JSON 字符串;fs 工具名 read/write/edit/read_image,参数 file_path。
4
4
  import path from 'node:path'
5
5
  import { createHash, randomUUID } from 'node:crypto'
6
- import { memoryRootFor } from '../util/fs.js'
7
- import { findProjectRoot } from '../lazy.js'
6
+ import { memoryRootFor, resolveProjectMemoryRoot } from '../util/fs.js'
8
7
  import { ProjectMemoryStore } from '../store.js'
8
+ import { stepContent, stepStatus } from '../util/task-view.js'
9
9
 
10
10
  const FS_FILE_TOOLS = new Set(['read', 'write', 'edit', 'read_image'])
11
11
  const WRITE_TOOLS = new Set(['write', 'edit']) // 编辑/写入 = 高热点
@@ -32,14 +32,24 @@ export function genTaskId(projectRoot, title) {
32
32
  return `tsk_${hash8(projectRoot)}_${slug}_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`
33
33
  }
34
34
 
35
- /** 项目根推导:findProjectRoot 期望文件路径,传目录会从父级起跳,故用目录内探针路径。 */
35
+ /** 会话根不可用时统一的用户提示(/tasks、/task、/insight 共用)。 */
36
+ export const NO_PROJECT_ROOT_NOTE =
37
+ '当前工作目录不是项目根(是家目录/系统目录且没有项目标记),项目记忆与任务清单已停用;请在项目目录内启动 dsh。'
38
+
39
+ /**
40
+ * 项目根推导:与全插件同一套策略(util/fs.js 的 `resolveProjectMemoryRoot`)——
41
+ * 最近的 VCS/清单标记优先;标记缺失但 cwd 本身是安全目录时退回 cwd(任务清单按
42
+ * "会话工作目录"归属,没有标记的普通目录也要能用);cwd 是家目录/系统/包管理器前缀
43
+ * 且上方没有标记时返回 null。
44
+ */
36
45
  export function projectRootFor(cwd) {
37
- const base = cwd || process.cwd()
38
- return findProjectRoot(path.join(base, '__taskbridge__.probe'))
46
+ return resolveProjectMemoryRoot(cwd || process.cwd())
39
47
  }
40
48
 
49
+ /** 没有可用项目根时返回 `{ root: null, store: null }`,调用方据此整段跳过。 */
41
50
  export function taskStoreFor(cwd, config) {
42
51
  const root = projectRootFor(cwd)
52
+ if (!root) return { root: null, store: null }
43
53
  return { root, store: new ProjectMemoryStore(memoryRootFor(root, config.memoryDir)).load() }
44
54
  }
45
55
 
@@ -63,8 +73,6 @@ export function firstTextOf(content) {
63
73
  return ''
64
74
  }
65
75
 
66
- const TODO_STATUSES = new Set(['pending', 'in_progress', 'completed'])
67
-
68
76
  /**
69
77
  * 反向接管:任务成为某会话绑定后,把任务步骤推成宿主 todo/write 快照,
70
78
  * 让 dsh 渲染的任务清单(UI/投影)变成我们这套任务的步骤。
@@ -75,11 +83,7 @@ export function adoptStepsToSession(session, task) {
75
83
  if (!session || typeof session.append !== 'function') return false
76
84
  const steps = Array.isArray(task?.steps) ? task.steps : null
77
85
  if (!steps || steps.length === 0) return false
78
- const todos = steps.map((s) => {
79
- const raw = typeof s === 'string' ? s : s?.content ?? s?.text ?? ''
80
- const status = TODO_STATUSES.has(s?.status) ? s.status : 'pending'
81
- return { content: String(raw), status }
82
- })
86
+ const todos = steps.map((s) => ({ content: String(stepContent(s)), status: stepStatus(s) }))
83
87
  session.append('todo/write', { todos })
84
88
  return true
85
89
  }
@@ -182,6 +186,7 @@ export function onSessionEvent(config, session, event, meta) {
182
186
  meta.set(sessionId, m)
183
187
  try {
184
188
  const { store } = taskStoreFor(session?.header?.cwd, config)
189
+ if (!store) return
185
190
  store.commit((s) => {
186
191
  const tid = s.getBoundTaskId(sessionId)
187
192
  const task = tid ? s.getTask(tid) : null
@@ -199,6 +204,8 @@ export function onSessionEvent(config, session, event, meta) {
199
204
  }
200
205
 
201
206
  const { root, store } = taskStoreFor(session?.header?.cwd, config)
207
+ // 没有项目根(家目录/系统目录会话):不建档、不记文件、不落任何盘。
208
+ if (!store) return
202
209
 
203
210
  if (type === 'todo/write') {
204
211
  const todos = event.data?.todos
@@ -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 forgetTool(config) {
@@ -23,7 +23,7 @@ export function forgetTool(config) {
23
23
  render: (_args, value) => [{ type: 'text', text: value }],
24
24
  },
25
25
  async execute(args, exec) {
26
- const root = resolveIndexRoot(exec, args.root)
26
+ const root = resolveSafeIndexRoot(exec, args.root, config)
27
27
  assertIndexRoot(root, args.root || root)
28
28
  const memoryDir = memoryRootFor(root, config.memoryDir)
29
29
  const store = new ProjectMemoryStore(memoryDir).load()
@@ -1,11 +1,10 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import path from 'node:path'
3
- import { assertReadableFile, memoryRootFor, sha256OfFile, storeKey } from '../util/fs.js'
3
+ import { assertIndexRoot, assertReadableFile, assertSafeRoot, findProjectRoot, memoryRootFor, sessionMemoryRootOrNull, sha256OfFile, storeKey } from '../util/fs.js'
4
4
  import { buildDocEntries } from '../doc-pipeline.js'
5
5
  import { docEntriesNeedBackfill } from '../doc-index.js'
6
6
  import { linkEntries } from '../link.js'
7
7
  import { ProjectMemoryStore } from '../store.js'
8
- import { findProjectRoot } from '../lazy.js'
9
8
 
10
9
  export function indexDocTool(ctx, config) {
11
10
  return defineTool({
@@ -22,16 +21,32 @@ export function indexDocTool(ctx, config) {
22
21
  },
23
22
  root: {
24
23
  type: 'string',
25
- description: 'Project root where the .dsh-project-memory store lives. Defaults to the file\'s directory.',
24
+ description: 'Project root where the .dsh-project-memory store lives. Defaults to the session\'s project root.',
26
25
  },
27
26
  },
28
27
  output: {
29
28
  schema: { type: 'string' },
30
29
  render: (_args, value) => [{ type: 'text', text: value }],
31
30
  },
32
- async execute(args) {
31
+ async execute(args, exec) {
33
32
  const filePath = assertReadableFile(args.file_path, config.maxFileSizeMb)
34
- const root = path.resolve(args.root && args.root.trim() ? args.root : findProjectRoot(filePath))
33
+ const explicit = args.root && args.root.trim() ? args.root : null
34
+ let root
35
+ if (explicit) {
36
+ root = path.resolve(explicit)
37
+ assertIndexRoot(root, explicit)
38
+ assertSafeRoot(root, { requested: explicit, allowUnsafe: config?.allowUnsafeRoots === true })
39
+ } else {
40
+ // 与懒索引同序:文件自身的项目标记优先,其次才是会话记忆根(安全但无标记的 cwd)。
41
+ const sessionRoot = sessionMemoryRootOrNull(exec, config)
42
+ root = findProjectRoot(filePath, { sessionRoot })
43
+ if (!root) {
44
+ return (
45
+ `Not indexed: ${filePath} is not inside a detected project ` +
46
+ '(no .git / package.json / pyproject.toml … marker above it). Pass root explicitly to place the memory store.'
47
+ )
48
+ }
49
+ }
35
50
  const memoryDir = memoryRootFor(root, config.memoryDir)
36
51
 
37
52
  const store = new ProjectMemoryStore(memoryDir).load()