@a9i5k4/dsh-auto-memory 0.1.8 → 0.1.10

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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-auto-memory — host half.
3
3
  *
4
- * 自动记忆系统,零运行时依赖(仅 node 内置模块):
4
+ * 集中式自动记忆系统,零运行时依赖(仅 node 内置模块):
5
5
  * - 三层记忆:用户级(~/.dsh/memory/MEMORY.md)、项目笔记({ws}/.dsh-memory/MEMORY.md)、
6
6
  * 每日日志({ws}/.dsh-memory/YYYY-MM-DD.md,append-only)
7
7
  * - 每次组装系统提示词时自动注入 <memory_system> 块(用户规则 + 项目笔记 + 今日日志 +
@@ -11,14 +11,20 @@
11
11
  * - 配置:~/DSH_HOME/dsh-auto-memory.json(存储位置、注入预算、反思风格等),UI 经
12
12
  * /api/dsh-auto-memory/config 读写
13
13
  * - 工具:memory_log / memory_note / memory_user / memory_recall / memory_maintain /
14
- * memory_status / memory_reflect
14
+ * memory_status / memory_reflect / memory_consolidate
15
+ * - 自动沉淀:每轮对话结束(turn-stopping)自动评估本轮内容,有记录价值的写今日日志
16
+ * ([自动沉淀] 标记),长期价值升格项目笔记/用户级记忆;寒暄轮跳过,按 turn 去重
15
17
  * - 路由:/api/dsh-auto-memory/{state,list,file,recall,config,reflect}(loopback-only)
16
18
  */
17
19
 
18
- import { readFile, writeFile, mkdir, readdir, stat, rm } from 'node:fs/promises'
20
+ import { readFile, writeFile, mkdir, readdir, stat, rm, copyFile } from 'node:fs/promises'
19
21
  import { createReadStream, existsSync } from 'node:fs'
22
+ import { exec as cpExec } from 'node:child_process'
23
+ import { promisify } from 'node:util'
24
+ const execP = promisify(cpExec)
20
25
  import { homedir } from 'node:os'
21
26
  import path from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
22
28
 
23
29
  /** Stable cordis plugin name. */
24
30
  export const name = 'auto-memory'
@@ -26,11 +32,11 @@ export const name = 'auto-memory'
26
32
  /** Services required before the memory surfaces can mount. */
27
33
  export const inject = ['webServer', 'tools', 'systemPrompt', 'subagents']
28
34
 
29
- /** Prompt order of the memory section (before the tool-guidance band 100+). */
30
- const SECTION_ORDER = 90
35
+ /** Prompt order of the memory section. 10000 = 末尾注入(紧跟用户消息,recency 最高,保证记忆纪律/自动沉淀说明被模型最后读到,遵循度更高) */
36
+ const SECTION_ORDER = 10000
31
37
 
32
38
  /** Model-facing announcement (tools + engine). */
33
- export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则;memory_recall 检索本地记忆 + 外部记忆(CodeBuddy/Claude Code/Codex AI 工具历史会话与画像)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。'
39
+ export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则;memory_recall 检索本地记忆 + 外部记忆(AI 助手/CodeBuddy/Claude Code/Codex 历史会话与画像)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,可在设置关闭),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。'
34
40
 
35
41
  /** Route family. */
36
42
  export const API = {
@@ -38,6 +44,13 @@ export const API = {
38
44
  list: '/api/dsh-auto-memory/list',
39
45
  file: '/api/dsh-auto-memory/file',
40
46
  recall: '/api/dsh-auto-memory/recall',
47
+ smartRecall: '/api/dsh-auto-memory/smart-recall',
48
+ workspaces: '/api/dsh-auto-memory/workspaces',
49
+ debug: '/api/dsh-auto-memory/debug',
50
+ browseDir: '/api/dsh-auto-memory/browse-dir',
51
+ pickDir: '/api/dsh-auto-memory/pick-dir',
52
+ updateCheck: '/api/dsh-auto-memory/update-check',
53
+ update: '/api/dsh-auto-memory/update',
41
54
  config: '/api/dsh-auto-memory/config',
42
55
  reflect: '/api/dsh-auto-memory/reflect',
43
56
  'reflect-auto': '/api/dsh-auto-memory/reflect-auto',
@@ -46,6 +59,7 @@ export const API = {
46
59
  'external-import': '/api/dsh-auto-memory/external-import',
47
60
  calendar: '/api/dsh-auto-memory/calendar',
48
61
  summarize: '/api/dsh-auto-memory/summarize',
62
+ greet: '/api/dsh-auto-memory/greet',
49
63
  }
50
64
 
51
65
  const DEFAULT_CONFIG = {
@@ -53,12 +67,20 @@ const DEFAULT_CONFIG = {
53
67
  userMemoryDir: '~/.dsh/memory',
54
68
  /** 项目级记忆目录名(相对工作区)。 */
55
69
  projectMemoryDir: '.dsh-memory',
70
+ /** 集中式记忆根目录(集中式):所有工作区的记忆统一存放,每工作区一个子目录(旧版分散在各工作区 .dsh-memory/ 会自动迁移)。 */
71
+ memoryRoot: '~/.dsh/memory/workspaces',
56
72
  /** 是否注入记忆上下文。 */
57
73
  injectEnabled: true,
58
74
  /** 注入总预算(字符)。 */
59
75
  injectBudgetChars: 2400,
60
76
  /** 注入的最近日志天数。 */
61
77
  recentDaysInjected: 3,
78
+ /** 每轮对话结束自动沉淀记忆(subagent 判断+提炼,有 API 成本;默认开)。 */
79
+ autoConsolidate: true,
80
+ /** 自动沉淀内容门槛:本轮 user+assistant 文本总字符数低于此值视为寒暄,跳过。 */
81
+ autoConsolidateMinChars: 60,
82
+ /** 日界(分钟,从 0 点起算):凌晨在此之前的活儿归前一天日志;默认 450=早上 7:30 后才进入新一天。 */
83
+ dayBoundaryMinutes: 450,
62
84
  /** 是否启用每日反思。 */
63
85
  reflectEnabled: true,
64
86
  /** 反思风格: auto=由内容决定 / life=生活化 / professional=专业性。 */
@@ -67,7 +89,7 @@ const DEFAULT_CONFIG = {
67
89
  locale: 'zh',
68
90
  /** 外部记忆注入预算(字符)。 */
69
91
  externalInjectionChars: 1400,
70
- /** 外部记忆源开关(CodeBuddy/Claude Code/Codex/项目约定等)。 */
92
+ /** 外部记忆源开关(AI 助手/CodeBuddy/Claude Code/Codex/项目约定)。 */
71
93
  externalSources: {
72
94
  'workbuddy-user': true,
73
95
  'workbuddy-profile': true,
@@ -84,6 +106,7 @@ const DEFAULT_CONFIG = {
84
106
  const pad = (n) => String(n).padStart(2, '0')
85
107
  const todayStr = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
86
108
  const nowHm = () => { const d = new Date(); return `${pad(d.getHours())}:${pad(d.getMinutes())}` }
109
+ const nowHms = () => { const d = new Date(); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` }
87
110
  const dateStrOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
88
111
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
89
112
  const truncateHead = (s, n) => (s && s.length > n) ? s.slice(0, n) + '\n…(截断,完整内容用 memory_recall 或 GUI 面板)' : (s || '')
@@ -114,6 +137,11 @@ class MemoryEngine {
114
137
  }
115
138
  this._configPath = path.join(dshHome(), 'dsh-auto-memory.json')
116
139
  this._readError = undefined
140
+ this._lastAgent = undefined // 最近一次 agent 引用(subagent parent 需要完整 agent 对象)
141
+ this._lastTurnByAgent = undefined // Map<agentId, turn>:自动沉淀去重(每轮只写一次)
142
+ this._consolidating = undefined // 自动沉淀进行中标记(防重入)
143
+ this._budgets = undefined // 每日写入预算:用户级4000/项目级3000字/天(所有会话共享,跨天重置)
144
+ this.autoStats = { count: 0, lastAt: 0, lastText: '', lastDate: '' } // 自动沉淀统计(GUI 即时反馈)
117
145
  this.external = new ExternalMemory(this)
118
146
  }
119
147
 
@@ -133,12 +161,48 @@ class MemoryEngine {
133
161
 
134
162
  async saveConfig(patch) {
135
163
  await this.loadConfig()
164
+ const oldRoot = this.expandUserPath(this.config.memoryRoot)
165
+ const oldUser = this.expandUserPath(this.config.userMemoryDir)
136
166
  this.config = { ...this.config, ...patch }
167
+ const newRoot = this.expandUserPath(this.config.memoryRoot)
168
+ const newUser = this.expandUserPath(this.config.userMemoryDir)
169
+ // 换存放位置时自动迁移旧文件(旧文件保留不删,新位置缺啥补啥),所有路径变量在下方 refresh 后全部跟随新配置
170
+ let migrated = ''
171
+ try {
172
+ if (oldRoot && newRoot && oldRoot !== newRoot) {
173
+ const olds = await readdir(oldRoot, { withFileTypes: true }).catch(() => [])
174
+ let n = 0
175
+ for (const en of olds) {
176
+ if (!en.isDirectory()) continue
177
+ const src = path.join(oldRoot, en.name)
178
+ const dst = path.join(newRoot, en.name)
179
+ try {
180
+ const exists = await stat(dst).catch(() => null)
181
+ if (!exists) { await this.copyDir(src, dst); n++ }
182
+ } catch (e) {}
183
+ }
184
+ if (n) migrated = '已把旧位置 ' + n + ' 个工作区记忆迁移到新根(旧文件保留未删)。'
185
+ }
186
+ if (oldUser && newUser && oldUser !== newUser) {
187
+ const olds = await readdir(oldUser, { withFileTypes: true }).catch(() => [])
188
+ let n = 0
189
+ for (const en of olds) {
190
+ if (!en.isFile()) continue
191
+ const src = path.join(oldUser, en.name)
192
+ const dst = path.join(newUser, en.name)
193
+ try {
194
+ const exists = await stat(dst).catch(() => null)
195
+ if (!exists) { await copyFile(src, dst); n++ }
196
+ } catch (e) {}
197
+ }
198
+ if (n) migrated += (migrated ? ' ' : '') + '已把用户级记忆 ' + n + ' 个文件迁移到新目录(旧文件保留未删)。'
199
+ }
200
+ } catch (e) { console.error('[dsh-auto-memory] config migrate failed', e) }
137
201
  await mkdir(path.dirname(this._configPath), { recursive: true })
138
202
  await writeFile(this._configPath, JSON.stringify(this.config, null, 2), 'utf8')
139
203
  this.state.loadedAt = 0 // 强制重载(目录可能变化)
140
204
  await this.refresh(undefined)
141
- return this.config
205
+ return { config: this.config, migrated }
142
206
  }
143
207
 
144
208
  // ---------- 路径 ----------
@@ -149,9 +213,46 @@ class MemoryEngine {
149
213
  return path.resolve(p)
150
214
  }
151
215
 
216
+ /** 工作区路径 → 记忆子目录名(与 ~/.dsh/sessions 目录风格一致,可读且唯一)。 */
217
+ wsKey(ws) {
218
+ if (!ws) return 'default'
219
+ return '--' + String(ws).replace(/[\\/:*?"<>|]/g, '-') + '--'
220
+ }
221
+
222
+ /** 集中式记忆根目录(集中式):所有工作区记忆统一存放,每工作区一个子目录。 */
152
223
  projectDirOf(ws) {
153
224
  const name = this.config.projectMemoryDir || '.dsh-memory'
154
- return path.isAbsolute(name) ? name : path.join(ws || process.cwd(), name)
225
+ if (path.isAbsolute(name)) return name // 旧用法:绝对路径兼容
226
+ const root = this.expandUserPath(this.config.memoryRoot) || path.join(dshHome(), 'memory', 'workspaces')
227
+ return path.join(root, this.wsKey(ws))
228
+ }
229
+
230
+ /** 递归复制目录(迁移用)。 */
231
+ async copyDir(src, dst) {
232
+ await mkdir(dst, { recursive: true })
233
+ const entries = await readdir(src, { withFileTypes: true })
234
+ for (const en of entries) {
235
+ const s = path.join(src, en.name)
236
+ const d = path.join(dst, en.name)
237
+ if (en.isDirectory()) await this.copyDir(s, d)
238
+ else if (en.isFile()) await copyFile(s, d)
239
+ }
240
+ }
241
+
242
+ /** 旧版分散结构({ws}/.dsh-memory) → 集中式根目录 自动迁移(复制,不删旧,安全)。 */
243
+ async migrateLegacy(ws, projectDir) {
244
+ try {
245
+ if (!ws) return
246
+ const legacy = path.join(ws, '.dsh-memory')
247
+ let legacyOk = false
248
+ try { legacyOk = (await stat(legacy)).isDirectory() } catch (e) {}
249
+ if (!legacyOk) return
250
+ let targetOk = false
251
+ try { targetOk = (await stat(projectDir)).isDirectory() } catch (e) {}
252
+ if (targetOk) return
253
+ await this.copyDir(legacy, projectDir)
254
+ console.log('[dsh-auto-memory] migrated memory: ' + legacy + ' -> ' + projectDir)
255
+ } catch (e) { console.error('[dsh-auto-memory] migrate failed', e) }
155
256
  }
156
257
 
157
258
  userDirOf() {
@@ -171,13 +272,81 @@ class MemoryEngine {
171
272
  calendarPath: path.join(userDir, 'CALENDAR.md'),
172
273
  projectDir,
173
274
  notesPath: path.join(projectDir, 'MEMORY.md'),
174
- logPath: path.join(projectDir, `${todayStr()}.md`),
275
+ logPath: path.join(projectDir, `${this.memToday()}.md`),
175
276
  reflectDir: path.join(projectDir, 'reflections'),
176
277
  greetDir: path.join(projectDir, 'greetings'),
177
- greetPath: path.join(projectDir, 'greetings', `${todayStr()}.md`),
278
+ greetPath: path.join(projectDir, 'greetings', `${this.memToday()}.json`),
279
+ greetPathLegacy: path.join(projectDir, 'greetings', `${this.memToday()}.md`),
178
280
  }
179
281
  }
180
282
 
283
+ // ---------- 更新检查(registry 自动检查 + profile 探测) ----------
284
+ /** 找 dsh web profile 目录(含 dsh-auto-memory 依赖/bundle 的那个)。 */
285
+ async findProfileDir() {
286
+ try {
287
+ const base = path.join(dshHome(), 'profiles')
288
+ const dirs = await readdir(base, { withFileTypes: true })
289
+ for (const d of dirs) {
290
+ if (!d.isDirectory()) continue
291
+ const pkgFile = path.join(base, d.name, 'package.json')
292
+ const raw = await this.readTextSafe(pkgFile)
293
+ if (!raw) continue
294
+ let pkg
295
+ try { pkg = JSON.parse(raw) } catch (e) { continue }
296
+ const deps = (pkg && pkg.dependencies) || {}
297
+ const bundles = (pkg && pkg.dsh && pkg.dsh.profile && pkg.dsh.profile.bundles) || []
298
+ const key = Object.keys(deps).find((k) => k.includes('dsh-auto-memory'))
299
+ const isLink = !!(key && typeof deps[key] === 'string' && deps[key].startsWith('link:'))
300
+ const isReg = !!deps['@a9i5k4/dsh-auto-memory'] || bundles.indexOf('@a9i5k4/dsh-auto-memory') >= 0
301
+ if (key || bundles.some((b) => b.includes('dsh-auto-memory'))) {
302
+ return {
303
+ dir: path.join(base, d.name),
304
+ installKind: isLink ? 'dev-link' : (isReg ? 'registry' : 'unknown'),
305
+ usesPnpm: existsSync(path.join(base, d.name, 'pnpm-lock.yaml')),
306
+ }
307
+ }
308
+ }
309
+ } catch (e) {}
310
+ return null
311
+ }
312
+
313
+ /** 对比本地版本与 npm registry 最新版;结果缓存 12 小时(启动/设置页打开都走缓存,不重复查网)。 */
314
+ async checkUpdate(force) {
315
+ const cacheFile = path.join(dshHome(), 'memory', 'update-check.json')
316
+ const base = { current: '', installKind: 'unknown' }
317
+ try {
318
+ const indexPath = fileURLToPath(import.meta.url)
319
+ const pkgPath = path.join(path.dirname(indexPath), '..', 'package.json')
320
+ const raw = await this.readTextSafe(pkgPath)
321
+ if (raw) { try { base.current = JSON.parse(raw).version || '' } catch (e) {} }
322
+ } catch (e) {}
323
+ const prof = await this.findProfileDir()
324
+ if (prof) base.installKind = prof.installKind
325
+ if (!force) {
326
+ try {
327
+ const cached = await this.readTextSafe(cacheFile)
328
+ if (cached) {
329
+ const j = JSON.parse(cached)
330
+ if (j && j.checkedAt && Date.now() - j.checkedAt < 12 * 3600 * 1000) return Object.assign({}, j, { fromCache: true })
331
+ }
332
+ } catch (e) {}
333
+ }
334
+ const result = Object.assign({}, base, { checkedAt: Date.now(), fromCache: false, latest: '', upToDate: false, error: '' })
335
+ try {
336
+ const rr = await fetch('https://registry.npmjs.org/@a9i5k4%2Fdsh-auto-memory', { signal: AbortSignal.timeout(8000) })
337
+ if (rr.ok) {
338
+ const j = await rr.json()
339
+ result.latest = (j && j['dist-tags'] && j['dist-tags'].latest) || ''
340
+ result.upToDate = !!(result.current && result.latest && result.current === result.latest)
341
+ } else result.error = 'registry HTTP ' + rr.status
342
+ } catch (e) { result.error = String(e && e.message ? e.message : e) }
343
+ try {
344
+ await mkdir(path.dirname(cacheFile), { recursive: true })
345
+ await writeFile(cacheFile, JSON.stringify(result, null, 2), 'utf8')
346
+ } catch (e) {}
347
+ return result
348
+ }
349
+
181
350
  // ---------- 读取 ----------
182
351
  async readTextSafe(p) {
183
352
  if (!p) return ''
@@ -231,7 +400,7 @@ class MemoryEngine {
231
400
  const logs = await this.listDailyLogs(projectDir, 30)
232
401
  const reflections = await this.listReflections(reflectDir, 30)
233
402
  const done = new Set(reflections.map((r) => r.date))
234
- const today = todayStr()
403
+ const today = this.memToday()
235
404
  for (const log of logs) {
236
405
  if (log.date >= today) continue
237
406
  if (done.has(log.date)) continue
@@ -257,6 +426,8 @@ class MemoryEngine {
257
426
  try {
258
427
  if (!this.configLoaded) await this.loadConfig()
259
428
  const p = await this.resolvePaths(agent)
429
+ // 旧版分散记忆({ws}/.dsh-memory) → 集中式根目录 自动迁移
430
+ await this.migrateLegacy(p.ws, p.projectDir)
260
431
  this.state.ws = p.ws
261
432
  this.state.userDir = p.userDir
262
433
  this.state.projectDir = p.projectDir
@@ -282,19 +453,160 @@ class MemoryEngine {
282
453
  const pending = await this.detectPendingReflection(p.projectDir, p.reflectDir)
283
454
  if (pending) this.state.pendingReflection = pending
284
455
  }
285
- // 今日拟人化问候(每天首会话展示一次)
286
- this.state.todayGreeting = await this.readTextSafe(p.greetPath)
456
+ // 今日拟人化问候(每天首会话展示一次;新 .json 优先,旧 .md 兼容)
457
+ this.state.todayGreeting = (await this.readTextSafe(p.greetPath)) || (await this.readTextSafe(p.greetPathLegacy))
287
458
  // 日历/日程(用户级,跨工作区与重装保留)
288
459
  this.state.calendarPath = p.calendarPath
289
460
  this.state.calendarText = await this.readTextSafe(p.calendarPath)
290
461
  // 外部记忆探测(后台,结果进缓存)
291
462
  if (this.config.externalSources) void this.external.discover(true)
463
+ // 记忆地图:其他工作区(名称+最近日志日期),供注入引导跨区检索
464
+ try {
465
+ const map = []
466
+ // 工作区发现 5 分钟缓存(周期刷新每 15s 触发一次,全量扫描 sessions 太贵)
467
+ let cwds
468
+ if (this._wsCache && Date.now() - this._wsCache.at < 5 * 60 * 1000) {
469
+ cwds = this._wsCache.list
470
+ } else {
471
+ cwds = await this.discoverWorkspaces()
472
+ this._wsCache = { at: Date.now(), list: cwds }
473
+ }
474
+ for (const cwd of cwds) {
475
+ if (cwd === p.ws) continue
476
+ const p2 = this.projectDirOf(cwd)
477
+ if (p2 === p.projectDir) continue
478
+ const logs2 = await this.listDailyLogs(p2, 1)
479
+ if (!logs2.length) continue
480
+ const name = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
481
+ map.push(name + '(最近日志 ' + logs2[0].date + ')')
482
+ }
483
+ this.state.workspaceMap = map
484
+ } catch (e) { this.state.workspaceMap = [] }
292
485
  this.state.loadedAt = Date.now()
293
486
  } catch (e) {
294
487
  console.error('[dsh-auto-memory] refresh failed', e)
295
488
  }
296
489
  }
297
490
 
491
+ /** 记忆日:日界(默认 7:30)前把凌晨归到前一天;日界后进入新一天。日志/沉淀/反思/问候/预算都按它切日。 */
492
+ memToday() {
493
+ const b = Number(this.config.dayBoundaryMinutes)
494
+ const boundary = Number.isFinite(b) && b >= 0 ? b : 450
495
+ const d = new Date()
496
+ if (d.getHours() * 60 + d.getMinutes() < boundary) d.setDate(d.getDate() - 1)
497
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
498
+ }
499
+
500
+ // ---------- 每日写入预算 + 超限自动压缩(硬约束,但不拒绝新内容) ----------
501
+ /**
502
+ * 用户级 ≤4000 字/天、项目级 ≤3000 字/天(所有会话共享一天预算,跨天重置)。
503
+ * 超限时不直接拒绝:先自动压缩"今天之前"的旧内容腾出空间(ensureBudget),压缩不可用时才拒绝。
504
+ */
505
+ accountWrite(agent, layer, text) {
506
+ if (!this._budgets) this._budgets = new Map()
507
+ const today = this.memToday()
508
+ let b = this._budgets.get('today')
509
+ if (!b || b.date !== today) { b = { date: today, user: 0, note: 0 }; this._budgets.set('today', b) }
510
+ const limit = layer === 'user' ? 4000 : 3000
511
+ const next = b[layer] + String(text || '').length
512
+ if (next > limit) return { ok: false, used: b[layer], limit, remaining: Math.max(0, limit - b[layer]) }
513
+ b[layer] = next
514
+ return { ok: true, used: next, limit, remaining: limit - next }
515
+ }
516
+
517
+ /** 预算保障:超限时自动压缩旧内容腾位;压缩成功返回 compacted:true,否则 ok:false。 */
518
+ async ensureBudget(agent, layer, text) {
519
+ const acct = this.accountWrite(agent, layer, text)
520
+ if (acct.ok) return { ok: true, acct }
521
+ const compacted = await this.compactLayer(agent, layer)
522
+ if (compacted) {
523
+ const acct2 = this.accountWrite(agent, layer, text)
524
+ if (acct2.ok) return { ok: true, acct: acct2, compacted: true }
525
+ }
526
+ return { ok: false, acct }
527
+ }
528
+
529
+ /** 压缩层内"今天之前"的旧段落:AI 提炼要点替换,不可用时归档最早段落;10 分钟节流。 */
530
+ async compactLayer(agent, layer) {
531
+ const now = Date.now()
532
+ if (this._lastCompactAt && now - this._lastCompactAt < 10 * 60 * 1000) return false
533
+ const p = await this.resolvePaths(agent)
534
+ const cur = (layer === 'user' ? this.state.userText : this.state.notesText) || ''
535
+ if (!cur.trim()) return true
536
+ const limit = layer === 'user' ? 4000 : 3000
537
+ const today = this.memToday()
538
+ // 按 "## 标题" 切段,今天段落保留,之前段落为压缩对象;文件头非 ## 内容归入头部段,不丢失
539
+ const segs = []
540
+ let curSeg = { title: '(文件头)', body: [], isToday: false }
541
+ for (const ln of cur.split('\n')) {
542
+ const m = ln.match(/^##\s+(.+)$/)
543
+ if (m) {
544
+ if (curSeg) segs.push(curSeg)
545
+ const title = m[1].trim()
546
+ curSeg = { title, body: [], isToday: /^\d{4}-\d{2}-\d{2}/.test(title) && title.slice(0, 10) === today }
547
+ } else if (curSeg) {
548
+ curSeg.body.push(ln)
549
+ }
550
+ }
551
+ if (curSeg) segs.push(curSeg)
552
+ const oldSegs = segs.filter((s) => !s.isToday)
553
+ const todaySegs = segs.filter((s) => s.isToday)
554
+ const oldText = oldSegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
555
+ const todayText = todaySegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
556
+ const target = Math.max(limit, 200) // 压缩后旧部分目标:不超过层上限
557
+ if (oldText.length <= target) return true // 旧内容本来就不大,无需压缩
558
+ let newOld = ''
559
+ if (this._subagents && agent) {
560
+ try {
561
+ const layerName = layer === 'user' ? '用户级记忆' : '项目笔记'
562
+ const prompt = [
563
+ '你是记忆整理员。下面的' + layerName + '文件里"今天之前"的旧内容需要压缩,因为今天要写入的新内容已超当日预算。',
564
+ '规则:',
565
+ '- 保留所有仍有长期价值的硬信息:强制规则、偏好、关键决策、约定、账号/路径等',
566
+ '- 合并重复、删除已失效/过期条目、把长句压到 20 字内',
567
+ '- 输出 markdown,可用"## 主题"分组,输出将直接替换旧内容',
568
+ '- 只输出压缩结果正文,不要任何解释',
569
+ '',
570
+ '待压缩旧内容:',
571
+ truncateTail(oldText, 6000),
572
+ ].join('\n')
573
+ const out = await this.runSubagent(prompt, 'auto-memory-compact', agent)
574
+ if (out && !out.includes('(无)')) newOld = out.trim()
575
+ } catch (e) {
576
+ console.error('[dsh-auto-memory] compactLayer AI failed', e && e.message ? e.message : e)
577
+ }
578
+ }
579
+ if (!newOld) {
580
+ // AI 不可用降级:把最早段落移入归档文件,直到旧部分 ≤ target(保底不丢)
581
+ const archiveFile = layer === 'user'
582
+ ? path.join(dshHome(), 'memory', 'archived-user.md')
583
+ : path.join(p.projectDir, 'archive', 'notes-archived.md')
584
+ await mkdir(path.dirname(archiveFile), { recursive: true })
585
+ let segs2 = oldSegs.slice()
586
+ let remain = oldText
587
+ while (remain.length > target && segs2.length) {
588
+ const seg = segs2.shift()
589
+ await this.appendText(archiveFile, '\n## ' + seg.title + '\n' + seg.body.join('\n'))
590
+ remain = segs2.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
591
+ }
592
+ newOld = remain.slice(0, target)
593
+ }
594
+ newOld = newOld.slice(0, target)
595
+ const body = [newOld, todayText].filter(Boolean).join('\n\n')
596
+ await this.writeFull(layer === 'user' ? p.userFile : p.notesPath, body)
597
+ if (layer === 'user') this.state.userText = body
598
+ else this.state.notesText = body
599
+ // 压缩腾出的空间让当日额度重新计(今天段落仍在文件里,不丢)
600
+ if (this._budgets) {
601
+ const b = this._budgets.get('today')
602
+ if (b && b.date === this.memToday()) b[layer] = 0
603
+ }
604
+ this._lastCompactAt = now
605
+ this.state.loadedAt = Date.now()
606
+ console.log('[dsh-auto-memory] compacted ' + layer + ' memory: ' + cur.length + ' -> ' + body.length + ' chars')
607
+ return true
608
+ }
609
+
298
610
  // ---------- 注入渲染(同步,基于缓存) ----------
299
611
  renderMemory(context) {
300
612
  const s = this.state
@@ -302,7 +614,11 @@ class MemoryEngine {
302
614
  const budget = Math.max(Number(cfg.injectBudgetChars) || 2400, 400)
303
615
  const lines = []
304
616
  lines.push('<memory_system>')
305
- lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + todayStr())
617
+ lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + this.memToday() + '(日界 ' + (Number(cfg.dayBoundaryMinutes) || 450) + ' 分钟,凌晨归前一天)' + (cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估'))
618
+ // 记忆地图:告诉模型其他工作区记忆存在,需要时用 memory_recall 跨区检索
619
+ if (s.workspaceMap && s.workspaceMap.length) {
620
+ lines.push('其他工作区记忆(开发/排查时可调用 memory_recall 检索其日志/笔记): ' + s.workspaceMap.join('、'))
621
+ }
306
622
  let used = 0
307
623
  const part = (title, text, max) => {
308
624
  if (!text) return
@@ -347,15 +663,20 @@ class MemoryEngine {
347
663
  lines.push('\n[欢迎回来]')
348
664
  lines.push('用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。')
349
665
  }
666
+ lines.push('\n[铭文 · 每轮提醒 ' + nowHms() + ']')
667
+ lines.push('思维链=本轮推理(用完即焚);铭文=落盘的记忆文件(跨会话永久)。你的记忆更新必须落在铭文层——显式调用工具写盘,不能只"想过"。')
668
+ lines.push('本提醒由框架在每一轮对话开始时重新注入(时间戳即刷新证明,衰减期只有一轮):读写只走工具、路径写死、每轮结束框架自动评估沉淀兜底——记忆无法被绕过,也不依赖自觉。')
350
669
  lines.push('\n[记忆写入纪律 — 必须遵守]')
351
- lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(CodeBuddy/Claude Code/Codex 等会话),不要凭空猜测**。')
670
+ lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(AI 助手/CodeBuddy/Claude Code/Codex 会话),不要凭空猜测**。')
352
671
  lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆;检索时在正文中说明"我先查一下之前的记录"。')
353
672
  lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
354
673
  lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
355
674
  lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
675
+ lines.push('- 每日写入预算(所有会话共享一天额度,跨天自动重置):用户级 ≤4000 字/天、项目笔记 ≤3000 字/天;超限不拒绝——框架自动把"今天之前"的旧内容交给 AI 浓缩成要点腾出空间(10 分钟内只压缩一次),AI 不可用时归档最早段落,信息不丢;每日日志无预算。')
356
676
  lines.push('- **记忆操作必须在正文可见(摘要链)**:调用 memory_log/note/user/reflect 更新记忆后,必须把结果写进本轮回复的正文文本(用户直接看到的那段文字,不是工具调用区),并在**回复末尾**用加粗或换行使其醒目(如"**已更新今日日志**\n新增:修复了XXX");调用 memory_recall/memory_external 检索时,在正文开头写明"我查了记忆,发现..."。工具返回值只是辅助,正文转述是强制要求。')
357
677
  lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
358
- lines.push('- 定期调用 memory_maintain 归档 30 天前日志;不存密钥,除非用户明确要求。')
678
+ lines.push('- 定期调用 memory_maintain 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
679
+ lines.push('- 自动沉淀:每轮对话结束,插件会自动评估本轮内容,把有记录价值的写进今日日志([自动沉淀] 标记),有长期价值的升格到项目笔记/用户级记忆,寒暄轮自动跳过。你仍须按上方纪律转述自己的显式记忆操作;也可调用 memory_consolidate 让 AI 读日志发散提炼长期要点。')
359
680
  lines.push('- 记忆仅作补充,不替代正常回复与交付物。')
360
681
  lines.push('</memory_system>')
361
682
  return lines.join('\n')
@@ -387,6 +708,14 @@ class MemoryEngine {
387
708
  greetingData() {
388
709
  const hour = new Date().getHours()
389
710
  const period = hour < 6 ? '凌晨' : hour < 9 ? '早上好' : hour < 12 ? '上午好' : hour < 14 ? '中午好' : hour < 18 ? '下午好' : hour < 22 ? '晚上好' : '夜深了'
711
+ // 时段 key(与 client 一致):凌晨/夜深 归入 morning/evening
712
+ const seg = hour < 6 ? 'morning' : hour < 9 ? 'morning' : hour < 12 ? 'forenoon' : hour < 14 ? 'noon' : hour < 18 ? 'afternoon' : 'evening'
713
+ // 问候:greetings/{date}.json 按时段存;旧 .md 纯文本兼容
714
+ let greetText = ''
715
+ const raw = this.state.todayGreeting || ''
716
+ if (raw) {
717
+ try { const j = JSON.parse(raw); greetText = (j && (j[seg] || '')) || '' } catch (e) { greetText = raw }
718
+ }
390
719
  // 昨天 = 最近一条日志(今天之前的);今天有条目也算最近
391
720
  const recent = this.state.recentLogs[0] || null
392
721
  const entries = recent ? recent.text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- ')).map((l) => {
@@ -395,9 +724,9 @@ class MemoryEngine {
395
724
  }) : []
396
725
  return {
397
726
  period,
398
- date: todayStr(),
399
- hasGreeting: !!this.state.todayGreeting,
400
- greeting: this.state.todayGreeting,
727
+ date: this.memToday(),
728
+ hasGreeting: !!greetText,
729
+ greeting: greetText,
401
730
  yesterdayDate: recent ? recent.date : '',
402
731
  entries,
403
732
  hasPendingReflection: !!this.state.pendingReflection,
@@ -426,7 +755,7 @@ class MemoryEngine {
426
755
  const p = await this.resolvePaths(agent)
427
756
  const out = []
428
757
  const hits = []
429
- const scanFile = async (label, filePath, maxMatches = 3) => {
758
+ const scanFile = async (label, filePath, maxMatches = 3, target = hits) => {
430
759
  const text = await this.readTextSafe(filePath)
431
760
  if (!text) return
432
761
  const matched = []
@@ -436,7 +765,7 @@ class MemoryEngine {
436
765
  if (matched.length >= maxMatches) break
437
766
  }
438
767
  }
439
- if (matched.length) hits.push({ where: label, matches: matched })
768
+ if (matched.length) target.push({ where: label, matches: matched })
440
769
  }
441
770
  // 读取顺序:progress(日志/反思)先行,再读 memory(用户级/项目笔记)
442
771
  const logs = await this.listDailyLogs(p.projectDir, 40)
@@ -455,11 +784,33 @@ class MemoryEngine {
455
784
  out.push('== 本地记忆文件命中 ==')
456
785
  for (const h of hits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
457
786
  }
787
+ // 其他 DSH 工作区记忆(跨工作区检索:从 sessions 发现的所有工作区,任何模型/新会话都能查到)
788
+ const otherHits = []
789
+ try {
790
+ const cwds = await this.discoverWorkspaces()
791
+ for (const cwd of cwds) {
792
+ if (hits.length + otherHits.length >= limit) break
793
+ if (cwd === p.ws) continue
794
+ const p2 = this.projectDirOf(cwd)
795
+ if (p2 === p.projectDir) continue
796
+ const wsName = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
797
+ const logs2 = await this.listDailyLogs(p2, 15)
798
+ for (const log of logs2) {
799
+ if (hits.length + otherHits.length >= limit) break
800
+ await scanFile('其他工作区[' + wsName + '] ' + log.name, path.join(p2, log.name), 2, otherHits)
801
+ }
802
+ await scanFile('其他工作区[' + wsName + '] 项目笔记', path.join(p2, 'MEMORY.md'), 2, otherHits)
803
+ }
804
+ } catch (e) {}
805
+ if (otherHits.length) {
806
+ out.push('== 其他工作区记忆命中(跨工作区) ==')
807
+ for (const h of otherHits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
808
+ }
458
809
  // 外部记忆(其他 AI 工具遗产)检索
459
810
  try {
460
811
  const extHits = await this.external.search(query, Math.max(limit - hits.length, 2))
461
812
  if (extHits.length) {
462
- out.push('== 外部记忆命中(CodeBuddy/Claude/Codex/项目约定等) ==')
813
+ out.push('== 外部记忆命中(AI 助手/CodeBuddy/Claude/Codex/项目约定) ==')
463
814
  for (const h of extHits) out.push('· ' + h.source + '(' + h.tool + '):\n' + h.lines.map((m) => ' - ' + m).join('\n'))
464
815
  }
465
816
  } catch (e) {}
@@ -484,6 +835,299 @@ class MemoryEngine {
484
835
  return '[记忆检索] "' + q + '":\n' + out.join('\n')
485
836
  }
486
837
 
838
+ /** 多关键词扫描三层记忆(供智能检索用),返回 {where, line} 列表。 */
839
+ async collectHits(keywords, limit) {
840
+ const p = await this.resolvePaths(undefined)
841
+ const hits = []
842
+ const seen = new Set()
843
+ const scanFile = async (label, filePath) => {
844
+ const text = await this.readTextSafe(filePath)
845
+ if (!text) return
846
+ let matched = 0
847
+ for (const line of text.split('\n')) {
848
+ const lt = line.toLowerCase()
849
+ if (keywords.some((k) => k && lt.includes(k))) {
850
+ const key = label + '|' + line.trim().slice(0, 80)
851
+ if (seen.has(key)) continue
852
+ seen.add(key)
853
+ hits.push({ where: label, line: line.trim().slice(0, 220) })
854
+ matched++
855
+ if (matched >= 2) break
856
+ }
857
+ }
858
+ }
859
+ const logs = await this.listDailyLogs(p.projectDir, 30)
860
+ for (const log of logs) { if (hits.length >= limit) break; await scanFile(log.name, path.join(p.projectDir, log.name)) }
861
+ const reflections = await this.listReflections(p.reflectDir, 20)
862
+ for (const rf of reflections) { if (hits.length >= limit) break; await scanFile('reflections/' + rf.name, path.join(p.reflectDir, rf.name)) }
863
+ await scanFile('~' + p.userFile.slice(homedir().length), p.userFile)
864
+ await scanFile(p.projectDir + '/MEMORY.md', p.notesPath)
865
+ return hits.slice(0, limit)
866
+ }
867
+
868
+ /** 智能检索:AI 发散关键词 → 扫描三层记忆 → AI 综合答案。 */
869
+ async smartRecall(query, agent) {
870
+ const q = String(query || '').trim()
871
+ if (!q) return { answer: '检索内容为空。', keywords: [], hits: [] }
872
+ // 第一轮:AI 把自然语言转成关键词
873
+ const kwPrompt = [
874
+ '你是记忆检索助手。用户想从记忆里查找信息,请把用户的自然语言描述转换成 3-6 个检索关键词(短词/短语,每行一个):',
875
+ '- 覆盖核心词、同义词、相关词(如"上次发布踩的坑" → 发布 / 踩坑 / 发布失败 / npm)',
876
+ '- 只输出关键词,每行一个,不要序号、不要解释、不要多余文字',
877
+ '',
878
+ '用户查询: ' + q,
879
+ ].join('\n')
880
+ const kwText = await this.withTimeout(this.runSubagent(kwPrompt, 'auto-memory-smart-kw', agent), 8000, '')
881
+ const keywords = (kwText || '').split('\n').map((l) => l.trim().replace(/^[-•\d.\s]+/, '')).filter((l) => l && l.length <= 24).slice(0, 6)
882
+ if (!keywords.length) keywords.push(q.slice(0, 20))
883
+ // 扫描三层记忆
884
+ const hits = await this.collectHits(keywords.map((k) => k.toLowerCase()), 14)
885
+ let answer = ''
886
+ if (!hits.length) {
887
+ answer = '没找到与"' + q + '"直接相关的记忆记录。可以换个说法,或试试普通关键词检索。'
888
+ } else {
889
+ const hitText = hits.map((h, i) => (i + 1) + '. [' + h.where + '] ' + h.line).join('\n')
890
+ const ansPrompt = [
891
+ '你是用户的记忆管家。根据下面检索命中的记忆片段,回答用户的查询。要求:',
892
+ '- 用自然语言回答(60-200字),像朋友交谈,不要罗列堆砌',
893
+ '- 引用命中里的关键信息(时间/项目/结论),并注明来自哪份记忆(日志日期/项目笔记/用户级记忆)',
894
+ '- 命中内容不足以回答时,如实说明,并概括命中了什么相关片段',
895
+ '- 不要编造记忆里没有的信息',
896
+ '',
897
+ '用户查询: ' + q,
898
+ '',
899
+ '检索关键词: ' + keywords.join(' / '),
900
+ '',
901
+ '命中片段:',
902
+ hitText,
903
+ ].join('\n')
904
+ answer = await this.withTimeout(this.runSubagent(ansPrompt, 'auto-memory-smart-ans', agent), 15000, '')
905
+ if (!answer) answer = '已检索到 ' + hits.length + ' 条相关记录,但 AI 综合失败(可能对话繁忙),请看下方命中明细。'
906
+ }
907
+ return { answer, keywords, hits: hits.map((h) => ({ where: h.where, line: h.line })) }
908
+ }
909
+
910
+ // ---------- 工作区总览(跨工作区全局总结) ----------
911
+ /** 读 jsonl 首行(session header)。 */
912
+ readFirstLine(p) {
913
+ return new Promise((resolve) => {
914
+ const rs = createReadStream(p, { encoding: 'utf8' })
915
+ let buf = ''
916
+ rs.on('data', (chunk) => {
917
+ buf += chunk
918
+ const i = buf.indexOf('\n')
919
+ if (i >= 0) { rs.destroy(); resolve(buf.slice(0, i)) }
920
+ })
921
+ rs.on('end', () => resolve(buf))
922
+ rs.on('error', () => resolve(''))
923
+ })
924
+ }
925
+
926
+ /** 扫描 ~/.dsh/sessions 下所有会话,提取去重后的工作区路径。 */
927
+ async discoverWorkspaces() {
928
+ const out = new Map()
929
+ const sessionsDir = path.join(dshHome(), 'sessions')
930
+ const walk = async (dir, depth) => {
931
+ if (depth > 4) return
932
+ let entries
933
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch (e) { return }
934
+ for (const en of entries) {
935
+ if (out.size >= 30) return
936
+ if (en.isDirectory()) await walk(path.join(dir, en.name), depth + 1)
937
+ else if (en.isFile() && en.name.endsWith('.jsonl')) {
938
+ try {
939
+ const first = await this.readFirstLine(path.join(dir, en.name))
940
+ if (first) {
941
+ const h = JSON.parse(first)
942
+ if (h && typeof h.cwd === 'string' && h.cwd) out.set(h.cwd, true)
943
+ }
944
+ } catch (e) {}
945
+ }
946
+ }
947
+ }
948
+ await walk(sessionsDir, 0)
949
+ return Array.from(out.keys())
950
+ }
951
+
952
+ /** 读取某工作区的近期记忆(最近5天日志 + 项目笔记头部),无 .dsh-memory 返回 null。 */
953
+ async readWorkspaceMemory(cwd) {
954
+ const dir = this.projectDirOf(cwd)
955
+ try { const st = await stat(dir); if (!st.isDirectory()) return null } catch (e) { return null }
956
+ const logs = []
957
+ try {
958
+ const entries = await readdir(dir, { withFileTypes: true })
959
+ const dates = entries.filter((en) => en.isFile() && DATE_RE.test(en.name.replace(/\.md$/, ''))).map((en) => en.name.slice(0, 10)).sort().reverse().slice(0, 5)
960
+ for (const d of dates) {
961
+ const t = await this.readTextSafe(path.join(dir, d + '.md'))
962
+ if (t) logs.push({ date: d, text: truncateTail(t, 1200) })
963
+ }
964
+ } catch (e) {}
965
+ const notes = await this.readTextSafe(path.join(dir, 'MEMORY.md'))
966
+ return { logs, notes: truncateHead(notes || '', 800) }
967
+ }
968
+
969
+ /** 工作区总览:每个工作区一个小总结 + 区内细分总结;结果缓存到用户级 ~/.dsh/memory/workspaces-summary.json(跨工作区全局)。 */
970
+ async workspaceOverview(agent, force) {
971
+ const cacheFile = path.join(dshHome(), 'memory', 'workspaces-summary.json')
972
+ if (!force) {
973
+ try {
974
+ const raw = await this.readTextSafe(cacheFile)
975
+ if (raw) {
976
+ const j = JSON.parse(raw)
977
+ if (j && Array.isArray(j.workspaces)) return { workspaces: j.workspaces, cached: true, generatedAt: j.generatedAt }
978
+ }
979
+ } catch (e) {}
980
+ }
981
+ const cwds = await this.discoverWorkspaces()
982
+ const workspaces = []
983
+ for (const cwd of cwds.slice(0, 8)) {
984
+ const mem = await this.readWorkspaceMemory(cwd)
985
+ if (!mem || (!mem.logs.length && !mem.notes)) continue
986
+ const name = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
987
+ let summary = ''
988
+ let items = []
989
+ if (mem.logs.length || mem.notes) {
990
+ const logText = mem.logs.map((l) => '[' + l.date + '] ' + l.text).join('\n')
991
+ const prompt = [
992
+ '你是用户的记忆管家。下面是某个工作区的近期记忆(最近5天日志+项目笔记),请:',
993
+ '1. 写一段 40-100 字的小总结:这个工作区在做什么、最近进展与主要成果,口语化一点。',
994
+ '2. 列出 2-5 条细分总结(每条 15-40 字),按日期或主题归纳(如"8-14: 发布 v0.1.9"、"进行中: 扩展框架")。',
995
+ '输出格式(严格遵守,不要多余文字):',
996
+ '[SUMMARY]',
997
+ '<小总结>',
998
+ '[ITEM]',
999
+ '<细分1>',
1000
+ '[ITEM]',
1001
+ '<细分2>',
1002
+ '',
1003
+ '工作区: ' + name,
1004
+ '',
1005
+ '近期日志:',
1006
+ logText.slice(0, 6000) || '(无)',
1007
+ '',
1008
+ '项目笔记:',
1009
+ mem.notes || '(无)',
1010
+ ].join('\n')
1011
+ const text = await this.withTimeout(this.runSubagent(prompt, 'auto-memory-ws-summary', agent), 30000, '')
1012
+ let cur = null
1013
+ for (const raw of String(text || '').split('\n')) {
1014
+ const l = raw.trim()
1015
+ if (!l) continue
1016
+ if (l.startsWith('[SUMMARY]')) { cur = 'summary'; continue }
1017
+ if (l.startsWith('[ITEM]')) { cur = 'item'; continue }
1018
+ if (cur === 'summary' && l) summary += (summary ? '\n' : '') + l
1019
+ else if (cur === 'item' && l && items.length < 6) items.push(l.slice(0, 120))
1020
+ }
1021
+ }
1022
+ workspaces.push({
1023
+ path: cwd,
1024
+ name,
1025
+ summary,
1026
+ items,
1027
+ logCount: mem.logs.reduce((a, l) => a + (l.text.split('\n').filter((x) => x.trim().startsWith('- ')).length), 0),
1028
+ dateRange: mem.logs.length ? (mem.logs[mem.logs.length - 1].date + ' ~ ' + mem.logs[0].date) : '',
1029
+ })
1030
+ }
1031
+ const result = { workspaces, generatedAt: Date.now() }
1032
+ try {
1033
+ await mkdir(path.dirname(cacheFile), { recursive: true })
1034
+ await writeFile(cacheFile, JSON.stringify(result, null, 2), 'utf8')
1035
+ } catch (e) {}
1036
+ return { workspaces, cached: false, generatedAt: result.generatedAt }
1037
+ }
1038
+
1039
+ // ---------- 调试中心(为提 issue 提供诊断信息) ----------
1040
+ async debugInfo() {
1041
+ const p = await this.resolvePaths(undefined)
1042
+ const startTime = Date.now() - process.uptime() * 1000
1043
+ // host 版本与文件时间(判断"代码改了但没重启")
1044
+ let version = ''
1045
+ let indexPath = ''
1046
+ let indexMtime = 0
1047
+ try {
1048
+ indexPath = fileURLToPath(import.meta.url)
1049
+ const pkgPath = path.join(path.dirname(indexPath), '..', 'package.json')
1050
+ const raw = await this.readTextSafe(pkgPath)
1051
+ if (raw) { try { version = JSON.parse(raw).version || '' } catch (e) {} }
1052
+ try { indexMtime = (await stat(indexPath)).mtimeMs } catch (e) {}
1053
+ } catch (e) {}
1054
+ // 轮询心跳
1055
+ const hbFile = path.join(dshHome(), 'memory', 'polling-heartbeat.json')
1056
+ let heartbeat = { exists: false }
1057
+ try {
1058
+ const raw = await this.readTextSafe(hbFile)
1059
+ if (raw) { heartbeat = Object.assign({ exists: true }, JSON.parse(raw)) }
1060
+ } catch (e) {}
1061
+ // subagents
1062
+ let providers = []
1063
+ try { providers = this._subagents && this._subagents.list ? this._subagents.list() : [] } catch (e) {}
1064
+ // 记忆文件状态
1065
+ const sizeMtime = async (f) => {
1066
+ try { const s = await stat(f); return { exists: true, size: s.size, mtime: s.mtimeMs } } catch (e) { return { exists: false } }
1067
+ }
1068
+ // 项目笔记重复日期标题检测
1069
+ let duplicateHeadings = 0
1070
+ try {
1071
+ const notes = await this.readTextSafe(p.notesPath)
1072
+ const seen = {}
1073
+ for (const line of String(notes || '').split('\n')) {
1074
+ const m = line.match(/^##\s+(\d{4}-\d{2}-\d{2})\s*$/)
1075
+ if (m) { seen[m[1]] = (seen[m[1]] || 0) + 1 }
1076
+ }
1077
+ duplicateHeadings = Object.values(seen).filter((n) => n > 1).length
1078
+ } catch (e) {}
1079
+ return {
1080
+ host: {
1081
+ pid: process.pid,
1082
+ startTime,
1083
+ uptimeSec: Math.round(process.uptime()),
1084
+ version,
1085
+ indexPath,
1086
+ indexMtime,
1087
+ needsRestart: !!(indexMtime && indexMtime > startTime + 5000),
1088
+ },
1089
+ config: this.config,
1090
+ heartbeat,
1091
+ autoConsolidate: {
1092
+ enabled: this.config.autoConsolidate !== false,
1093
+ minChars: Math.max(Number(this.config.autoConsolidateMinChars) || 60, 20),
1094
+ consolidating: !!this._consolidating,
1095
+ pendingQueue: (this._pendingConsolidations || []).length,
1096
+ stats: this.autoStats,
1097
+ },
1098
+ subagents: { available: !!this._subagents, providers },
1099
+ memoryFiles: {
1100
+ user: await sizeMtime(p.userFile),
1101
+ notes: await sizeMtime(p.notesPath),
1102
+ log: await sizeMtime(p.logPath),
1103
+ reflectionsDir: await sizeMtime(p.reflectDir),
1104
+ calendar: await sizeMtime(p.calendarPath),
1105
+ workspacesCache: await sizeMtime(path.join(dshHome(), 'memory', 'workspaces-summary.json')),
1106
+ summariesDir: await sizeMtime(path.join(p.projectDir, 'summaries')),
1107
+ },
1108
+ duplicateHeadings,
1109
+ budgets: (() => {
1110
+ const out = []
1111
+ if (this._budgets) {
1112
+ for (const [, b] of this._budgets.entries()) {
1113
+ out.push({
1114
+ scope: 'daily', date: b.date,
1115
+ userUsed: b.user, userLimit: 4000,
1116
+ noteUsed: b.note, noteLimit: 3000,
1117
+ userFileSize: (this.state.userText || '').length,
1118
+ noteFileSize: (this.state.notesText || '').length,
1119
+ lastCompactAt: this._lastCompactAt || 0,
1120
+ })
1121
+ }
1122
+ }
1123
+ return out
1124
+ })(),
1125
+ today: todayStr(),
1126
+ memoryDate: this.memToday(),
1127
+ now: Date.now(),
1128
+ }
1129
+ }
1130
+
487
1131
  // ---------- 反思 ----------
488
1132
  async saveReflection(date, text, agent) {
489
1133
  if (!DATE_RE.test(date)) return 'memory_reflect: date 必须是 YYYY-MM-DD。'
@@ -594,33 +1238,370 @@ class MemoryEngine {
594
1238
  }
595
1239
 
596
1240
  /** 生活化总结:调用 DSH 的 AI(subagent)发散地总结某时段的工作(完成/收尾/烦恼/暂停的事)。 */
597
- async summarizePeriod(period, agent) {
1241
+ async summarizePeriod(period, agent, force) {
598
1242
  const ps = this.periodSummary()
599
1243
  const groups = ps.groups || {}
600
1244
  const items = (period === '昨天') ? (this.state.recentLogs[0] ? this.state.recentLogs[0].text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- ')).map((l) => l.replace(/^- /, '')) : []) : (groups[period] || [])
601
- if (!items.length) return ''
602
- const subagents = this._subagents
603
- if (!subagents) return '' // 无 subagents 服务时前端只显示条目
604
- const parent = agent && agent.session ? agent.session.id : undefined
1245
+ if (!items.length) return { summary: '', works: [], cached: true, generatedAt: Date.now() }
1246
+ // 缓存文件(.dsh-memory/summaries/),force=false 且缓存存在时直接返回,不重复生成
1247
+ let cacheFile = ''
1248
+ try {
1249
+ const p = await this.resolvePaths(agent)
1250
+ cacheFile = path.join(p.projectDir, 'summaries', this.memToday() + '-' + period + '.json')
1251
+ } catch (e) {}
1252
+ if (!force && cacheFile) {
1253
+ try {
1254
+ const raw = await this.readTextSafe(cacheFile)
1255
+ if (raw) {
1256
+ const j = JSON.parse(raw)
1257
+ if (j && j.summary && Array.isArray(j.works)) return { summary: j.summary, works: j.works, generatedAt: j.generatedAt || Date.now(), cached: true }
1258
+ }
1259
+ } catch (e) {}
1260
+ }
605
1261
  const prompt = [
606
- '你是一个温暖、细腻的生活助理。请根据下面这位用户的时段工作记录,写一段自然、口语化的总结(80-160字),像朋友聊天一样:',
607
- '- 概括这个时段完成了什么(挑重要的说,不用逐条罗列)',
608
- '- 提到是否收尾/完成(如果有明显没做完的迹象就轻轻点一下,不要断言)',
609
- '- 如果看得出可能的烦恼或卡住的事,温和地提一句;看不出就跳过',
610
- '- 语气轻松自然,不要列表、不要小标题、不要"总结:"前缀',
1262
+ '你是一个温暖、细腻的生活助理。请阅读用户某时段的原始工作记录,做两件事:',
1263
+ '1. 写一段生活化总结(80-160字),像朋友聊天:概括完成的事、是否收尾、可能的烦恼(温和带过,看不出就跳过);不要列表、不要小标题、不要"总结:"前缀。',
1264
+ '2. 把原始记录归纳成若干项"工作"(3-6项),每项给简短标题(8-16字),并列出该工作的细点(每项2-4条,每条20-40字,从原始记录提炼精简,不要逐字复制长句)。',
1265
+ '输出格式(严格遵守,不要多余文字):',
1266
+ '[SUMMARY]',
1267
+ '<生活化总结>',
1268
+ '[WORK] <工作标题1>',
1269
+ '- <细点1>',
1270
+ '- <细点2>',
1271
+ '[WORK] <工作标题2>',
1272
+ '- <细点1>',
611
1273
  '',
612
1274
  '用户时段工作记录(' + period + '):',
613
1275
  items.map((x) => '- ' + x).join('\n'),
614
1276
  ].join('\n')
1277
+ const text = await this.runSubagent(prompt, 'auto-memory-summarize', agent)
1278
+ if (!text) return { summary: '', works: [], cached: false, generatedAt: Date.now() }
1279
+ // 解析 [SUMMARY]/[WORK]/- 结构
1280
+ let summary = ''
1281
+ const works = []
1282
+ let cur = null
1283
+ for (const raw of text.split('\n')) {
1284
+ const l = raw.trim()
1285
+ if (!l) continue
1286
+ if (l.startsWith('[SUMMARY]')) continue
1287
+ if (l.startsWith('[WORK]')) { cur = { title: l.slice(6).trim(), points: [] }; works.push(cur); continue }
1288
+ if (l.startsWith('- ') || l.startsWith('• ')) {
1289
+ const pt = l.replace(/^[-•]\s*/, '').trim()
1290
+ if (cur && pt) cur.points.push(pt)
1291
+ else if (pt) summary += (summary ? '\n' : '') + pt
1292
+ continue
1293
+ }
1294
+ if (!cur && l) summary += (summary ? '\n' : '') + l
1295
+ }
1296
+ const result = { summary, works, generatedAt: Date.now(), cached: false }
1297
+ if (cacheFile) {
1298
+ try { await mkdir(path.dirname(cacheFile), { recursive: true }); await writeFile(cacheFile, JSON.stringify(result, null, 2), 'utf8') } catch (e) {}
1299
+ }
1300
+ return result
1301
+ }
1302
+
1303
+ /** 今日 AI 问候(按时段,每天每时段生成一次并缓存到 greetings/{date}.json)。 */
1304
+ async greetToday(agent) {
1305
+ const hour = new Date().getHours()
1306
+ const seg = hour < 6 ? 'morning' : hour < 9 ? 'morning' : hour < 12 ? 'forenoon' : hour < 14 ? 'noon' : hour < 18 ? 'afternoon' : 'evening'
1307
+ const segLabel = { morning: '早上', forenoon: '上午', noon: '中午', afternoon: '下午', evening: '晚上' }[seg]
1308
+ const p = await this.resolvePaths(agent)
1309
+ const greetFile = path.join(p.greetDir, this.memToday() + '.json')
1310
+ // 已有该时段缓存 → 直接返回
615
1311
  try {
616
- const run = await subagents.start('spawn', { label: 'auto-memory-summarize', prompt, ...(parent ? { parent } : {}) })
617
- const result = await run
618
- const text = result && result.text ? String(result.text) : ''
619
- return text.trim()
1312
+ const raw = await this.readTextSafe(greetFile)
1313
+ if (raw) {
1314
+ const j = JSON.parse(raw)
1315
+ if (j && j[seg]) return { greeting: j[seg], cached: true }
1316
+ }
1317
+ } catch (e) {}
1318
+ // 今日记录(供问候引用最近几条)
1319
+ const items = (this.state.logText || '').split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- ')).map((l) => l.replace(/^- \d{2}:\d{2} /, '').replace(/^- /, '')).slice(-6)
1320
+ const prompt = [
1321
+ '你是一个温暖的生活助理。现在是' + segLabel + ',请给用户写一句简短的拟人化问候(30-60字),像朋友一样:',
1322
+ '- 按时间段自然问候(' + segLabel + '好/辛苦了之类),自然提起今天完成的主要工作(挑1-2件最重要的),可以带一句贴心提醒(如早点休息)',
1323
+ '- 语气轻松温暖,不要列表、不要"总结:"、不要感叹号堆砌、不要 emoji',
1324
+ '- 只输出问候语本身,不要任何前后缀',
1325
+ '',
1326
+ '今天已记录的工作(选重要的提):',
1327
+ (items.length ? items.map((x) => '- ' + x).join('\n') : '(今天还没有记录)'),
1328
+ ].join('\n')
1329
+ const text = await this.runSubagent(prompt, 'auto-memory-greet', agent)
1330
+ if (!text) return { greeting: '', cached: false }
1331
+ // 合并写缓存(同一天多时段共存)
1332
+ let all = {}
1333
+ try { const raw = await this.readTextSafe(greetFile); if (raw) { const j = JSON.parse(raw); if (j) all = j } } catch (e) {}
1334
+ all[seg] = text
1335
+ try { await mkdir(p.greetDir, { recursive: true }); await writeFile(greetFile, JSON.stringify(all, null, 2), 'utf8') } catch (e) {}
1336
+ return { greeting: text, cached: false }
1337
+ }
1338
+
1339
+ /** 调用 DSH subagent 发散/提炼一段文本(90s 超时,结果取 text 块;parent 用最近 agent)。 */
1340
+ async runSubagent(text, label, agent) {
1341
+ const subagents = this._subagents
1342
+ if (!subagents) return ''
1343
+ // parent 必须是完整 agent 对象(captureDelegatedPolicyOverrides 读 parent.ctx/parent.session);路由调用用缓存的最近 agent
1344
+ const parent = agent || this._lastAgent
1345
+ // 动态选择可用的 subagent provider(优先 spawn,否则取已注册的第一个)
1346
+ let providerName = 'spawn'
1347
+ let registered = []
1348
+ try {
1349
+ registered = subagents.list ? subagents.list() : []
1350
+ if (Array.isArray(registered) && registered.length && !registered.includes('spawn')) providerName = registered[0]
1351
+ } catch (e2) {}
1352
+ const controller = new AbortController()
1353
+ const timer = setTimeout(() => controller.abort(label + ' timeout'), 90000)
1354
+ try {
1355
+ // prompt 必须是 block 数组(createUserMessage 校验 content.some)
1356
+ const run = await subagents.start(providerName, {
1357
+ label,
1358
+ prompt: [{ type: 'text', text }],
1359
+ signal: controller.signal,
1360
+ ...(parent ? { parent } : {}),
1361
+ })
1362
+ const result = await run.result
1363
+ const blocks = result && result.output ? result.output : []
1364
+ return blocks.filter((b) => b && b.type === 'text').map((b) => b.text).join('').trim()
620
1365
  } catch (e) {
621
- console.error('[dsh-auto-memory] summarize failed', e && e.message ? e.message : e)
1366
+ console.error('[dsh-auto-memory] ' + label + ' failed', e && e.message ? e.message : e)
622
1367
  return ''
1368
+ } finally {
1369
+ clearTimeout(timer)
1370
+ }
1371
+ }
1372
+
1373
+ /** 带超时的 promise(超时返回 fallback,不无限等待)。 */
1374
+ async withTimeout(promise, ms, fallback) {
1375
+ let timer
1376
+ try {
1377
+ return await Promise.race([
1378
+ promise,
1379
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout after ' + ms + 'ms')), ms) }),
1380
+ ])
1381
+ } catch (e) { return fallback }
1382
+ finally { clearTimeout(timer) }
1383
+ }
1384
+
1385
+ /** 每轮对话结束自动沉淀:取本轮 user+assistant 消息 → subagent 判断/提炼 → 写今日日志+升格。 */
1386
+ async consolidateTurn(turn, agent) {
1387
+ if (this._consolidating) return
1388
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
1389
+ if (this.config.autoConsolidate === false) return
1390
+ if (!agent || !agent.session) return
1391
+ // 只处理顶层会话(子代理/接续会话的 header.parentSession 非空,避免子代理轮次误沉淀)
1392
+ try { if (agent.session.header && agent.session.header.parentSession) return } catch (e) {}
1393
+ const minChars = Math.max(Number(this.config.autoConsolidateMinChars) || 60, 20)
1394
+ // 按 turn 去重:同一 agent 的同一轮只处理一次
1395
+ const agentId = agent.id || (agent.session && agent.session.id) || '?'
1396
+ if (!this._lastTurnByAgent) this._lastTurnByAgent = new Map()
1397
+ const lastTurn = this._lastTurnByAgent.get(agentId)
1398
+ if (lastTurn === turn) return
1399
+ this._lastTurnByAgent.set(agentId, turn)
1400
+ // 取本轮最后一条 user + 最后一条 assistant(模型可见消息序列)
1401
+ const messages = extractSessionMessages(agent)
1402
+ if (messages.length < 2) return
1403
+ let userText = ''
1404
+ let assistantText = ''
1405
+ for (const m of messages) {
1406
+ if (m.role === 'user') userText = m.text
1407
+ else if (m.role === 'assistant' && m.text) assistantText = m.text
1408
+ }
1409
+ if (!userText.trim() || !assistantText.trim()) return
1410
+ const combined = userText + '\n' + assistantText
1411
+ if (combined.length < minChars) return
1412
+ this._consolidating = (async () => {
1413
+ try {
1414
+ await this.refresh(agent)
1415
+ const p = await this.resolvePaths(agent)
1416
+ // 今日日志尾部(去重参考)
1417
+ const logTail = truncateTail(this.state.logText || '', 900)
1418
+ const prompt = [
1419
+ '你是用户的记忆管家。刚结束一轮对话,请判断其中是否有值得写入记忆的内容,并提炼要点。',
1420
+ '规则:',
1421
+ '- 寒暄、闲聊、单纯问候、纯测试、无实质内容 → 只输出 (无)',
1422
+ '- 有实质内容(完成工作、修复问题、做出决策、约定规则、用户偏好、讨论结论)时,提炼 1-3 条要点',
1423
+ '- 每条一句话,具体明确,不要泛泛而谈,不要复述对话过程',
1424
+ '- 项目专属的进度 → [LOG];有跨会话长期价值的项目决策/架构/约定 → [NOTE];跨项目通用的用户硬性规则/偏好 → [USER]',
1425
+ '- [NOTE]/[USER] 只在真正长期有价值时用,宁缺毋滥',
1426
+ '输出格式(严格遵守):',
1427
+ '[TOPIC]',
1428
+ '<本轮工作主题标题,8-20字,如"修复抽屉bug与字号功能">',
1429
+ '[LOG]',
1430
+ '- 要点1',
1431
+ '[NOTE]',
1432
+ '- 要点2',
1433
+ '[USER]',
1434
+ '- 规则',
1435
+ '没有值得记录的内容时只输出一行:(无)',
1436
+ '',
1437
+ '本轮用户消息:',
1438
+ userText.slice(0, 3000),
1439
+ '',
1440
+ '本轮助手回复:',
1441
+ assistantText.slice(0, 3000),
1442
+ '',
1443
+ '今日日志已有内容(避免重复记录):',
1444
+ logTail || '(空)',
1445
+ ].join('\n')
1446
+ const text = await this.runSubagent(prompt, 'auto-memory-consolidate', agent)
1447
+ if (!text) {
1448
+ // subagent 失败(返回空):入重试队列,由后台轮询兜底重试
1449
+ if (!this._pendingConsolidations) this._pendingConsolidations = []
1450
+ if (this._pendingConsolidations.length < 5) this._pendingConsolidations.push({ turn, agent })
1451
+ return
1452
+ }
1453
+ if (text.includes('(无)')) return
1454
+ const logPts = []
1455
+ const notePts = []
1456
+ const userPts = []
1457
+ let topicTitle = ''
1458
+ let section = ''
1459
+ for (const raw of text.split('\n')) {
1460
+ const l = raw.trim()
1461
+ if (!l) continue
1462
+ if (l === '[LOG]') { section = 'log'; continue }
1463
+ if (l === '[NOTE]') { section = 'note'; continue }
1464
+ if (l === '[USER]') { section = 'user'; continue }
1465
+ if (l === '[TOPIC]') { section = 'topic'; continue }
1466
+ if (section === 'topic') { if (!topicTitle) topicTitle = l.slice(0, 30); continue }
1467
+ if (l.startsWith('- ') && (section === 'log' || section === 'note' || section === 'user')) {
1468
+ const pt = l.slice(2).trim()
1469
+ if (pt) {
1470
+ if (section === 'log') logPts.push(pt)
1471
+ else if (section === 'note') notePts.push(pt)
1472
+ else userPts.push(pt)
1473
+ }
1474
+ }
1475
+ }
1476
+ const today = this.memToday()
1477
+ let written = 0
1478
+ if (logPts.length) {
1479
+ // 集中式主题分组: ## 主题(HH:MM) + 要点列表(自动沉淀不再混入时间戳流水)
1480
+ const topic = (topicTitle || '自动沉淀') + '(' + nowHm() + ')'
1481
+ const body = await this.appendText(p.logPath, '\n## ' + topic + '\n' + logPts.map((x) => '- ' + x).join('\n'))
1482
+ this.state.logText = body; this.state.logPath = p.logPath
1483
+ written += logPts.length
1484
+ }
1485
+ if (notePts.length) {
1486
+ const r = await this.ensureBudget(agent, 'note', notePts.map((x) => '- ' + x).join('\n'))
1487
+ if (r.ok) {
1488
+ const body = await this.appendText(p.notesPath, '\n## ' + today + '\n' + notePts.map((x) => '- ' + x).join('\n'))
1489
+ this.state.notesText = body
1490
+ written += notePts.length
1491
+ } else {
1492
+ console.log('[dsh-auto-memory] auto-consolidate: NOTE 超预算且压缩不可用,已跳过')
1493
+ }
1494
+ }
1495
+ if (userPts.length) {
1496
+ const r = await this.ensureBudget(agent, 'user', userPts.map((x) => '- ' + x).join('\n'))
1497
+ if (r.ok) {
1498
+ const body = await this.appendText(p.userFile, '\n## ' + today + '\n' + userPts.map((x) => '- ' + x).join('\n'))
1499
+ this.state.userText = body
1500
+ written += userPts.length
1501
+ } else {
1502
+ console.log('[dsh-auto-memory] auto-consolidate: USER 超预算且压缩不可用,已跳过')
1503
+ }
1504
+ }
1505
+ if (written) {
1506
+ // 自动沉淀统计(跨天重置)
1507
+ if (this.autoStats.lastDate !== today) { this.autoStats.count = 0; this.autoStats.lastDate = today }
1508
+ this.autoStats.count += written
1509
+ this.autoStats.lastAt = Date.now()
1510
+ this.autoStats.lastText = (logPts[0] || notePts[0] || userPts[0] || '').slice(0, 80)
1511
+ this.state.loadedAt = Date.now()
1512
+ console.log('[dsh-auto-memory] auto-consolidated turn ' + turn + ': +' + written + ' points (log ' + logPts.length + ', note ' + notePts.length + ', user ' + userPts.length + ')')
1513
+ }
1514
+ } catch (e) {
1515
+ console.error('[dsh-auto-memory] consolidateTurn failed', e && e.message ? e.message : e)
1516
+ } finally {
1517
+ this._consolidating = undefined
1518
+ }
1519
+ })()
1520
+ await this._consolidating
1521
+ }
1522
+
1523
+ /** AI 主动固化(做梦式):读最近日志 → 发散提炼 → 项目笔记/用户级 MEMORY.md 带日期标题。 */
1524
+ async consolidateMemory(agent, days = 7) {
1525
+ const subagents = this._subagents
1526
+ if (!subagents) return 'memory_consolidate: subagents 服务不可用,无法调用 AI 提炼。'
1527
+ const p = await this.resolvePaths(agent)
1528
+ const logs = await this.listDailyLogs(p.projectDir, 60)
1529
+ const recent = []
1530
+ for (const log of logs) {
1531
+ if (recent.length >= days) break
1532
+ const text = await this.readTextSafe(path.join(p.projectDir, log.name))
1533
+ if (text && text.trim()) recent.push({ date: log.date, text: truncateTail(text, 2500) })
1534
+ }
1535
+ if (!recent.length) return 'memory_consolidate: 最近 ' + days + ' 天没有日志,没有可提炼的内容。'
1536
+ const prompt = [
1537
+ '你是用户的长期记忆管家。请阅读下面的工作日志,发散提炼出值得长期记住的内容,把记忆固化成条目。',
1538
+ '规则:',
1539
+ '- 只提炼有跨会话长期价值的:技术决策、架构约定、关键路径、用户偏好/习惯、踩过的坑及其规则',
1540
+ '- 不记临时信息(某次具体修 bug 的过程省略,但其背后的规则/约定值得记)',
1541
+ '- 项目专属 → [PROJECT];跨项目通用的用户硬性规则/偏好 → [USER]',
1542
+ '- 每条一句话,简短明确;已经在下方"已有记忆"里出现的不要重复',
1543
+ '输出格式(严格遵守):',
1544
+ '[PROJECT]',
1545
+ '- 要点1',
1546
+ '- 要点2',
1547
+ '[USER]',
1548
+ '- 规则1',
1549
+ '若没有任何值得长期记录的内容,只输出一行:(无)',
1550
+ '',
1551
+ '最近 ' + days + ' 天日志:',
1552
+ recent.map((r) => '### ' + r.date + '\n' + r.text).join('\n\n'),
1553
+ '',
1554
+ '已有项目笔记(尾部):',
1555
+ truncateTail(this.state.notesText || '', 1200) || '(空)',
1556
+ '',
1557
+ '已有用户级记忆(尾部):',
1558
+ truncateTail(this.state.userText || '', 1200) || '(空)',
1559
+ ].join('\n')
1560
+ const text = await this.runSubagent(prompt, 'auto-memory-consolidate-logs', agent)
1561
+ if (!text) return 'memory_consolidate: AI 提炼失败(超时或 subagent 不可用),未写入任何内容。'
1562
+ if (text.includes('(无)')) return 'memory_consolidate: AI 判断最近日志没有值得长期记录的新内容。'
1563
+ const projectPts = []
1564
+ const userPts = []
1565
+ let section = ''
1566
+ for (const raw of text.split('\n')) {
1567
+ const l = raw.trim()
1568
+ if (!l) continue
1569
+ if (l === '[PROJECT]') { section = 'project'; continue }
1570
+ if (l === '[USER]') { section = 'user'; continue }
1571
+ if (l.startsWith('- ') && section) {
1572
+ const pt = l.slice(2).trim()
1573
+ if (pt) { if (section === 'project') projectPts.push(pt); else userPts.push(pt) }
1574
+ }
1575
+ }
1576
+ const today = this.memToday()
1577
+ const written = []
1578
+ const skipped = []
1579
+ if (projectPts.length) {
1580
+ const r = await this.ensureBudget(agent, 'note', projectPts.map((x) => '- ' + x).join('\n'))
1581
+ if (r.ok) {
1582
+ const body = await this.appendText(p.notesPath, '\n## ' + today + '\n' + projectPts.map((x) => '- ' + x).join('\n'))
1583
+ this.state.notesText = body
1584
+ written.push('项目笔记 ' + projectPts.length + ' 条')
1585
+ } else skipped.push('项目笔记(预算超限且压缩不可用)')
623
1586
  }
1587
+ if (userPts.length) {
1588
+ const r = await this.ensureBudget(agent, 'user', userPts.map((x) => '- ' + x).join('\n'))
1589
+ if (r.ok) {
1590
+ const body = await this.appendText(p.userFile, '\n## ' + today + '\n' + userPts.map((x) => '- ' + x).join('\n'))
1591
+ this.state.userText = body
1592
+ written.push('用户级记忆 ' + userPts.length + ' 条')
1593
+ } else skipped.push('用户级记忆(预算超限且压缩不可用)')
1594
+ }
1595
+ this.state.loadedAt = Date.now()
1596
+ if (!written.length) {
1597
+ if (skipped.length) return 'memory_consolidate: AI 已提炼,但今日写入预算已用尽,以下层被跳过(明天再固化): ' + skipped.join('、') + '。'
1598
+ return 'memory_consolidate: AI 未提炼出值得长期记录的内容。'
1599
+ }
1600
+ const detail = []
1601
+ if (projectPts.length) detail.push('项目笔记新增:\n' + projectPts.map((x) => '- ' + x).join('\n'))
1602
+ if (userPts.length) detail.push('用户级记忆新增:\n' + userPts.map((x) => '- ' + x).join('\n'))
1603
+ if (skipped.length) detail.push('被今日预算跳过: ' + skipped.join('、'))
1604
+ return 'memory_consolidate 完成,已固化 ' + written.join('、') + '(带日期标题)。\n' + detail.join('\n\n')
624
1605
  }
625
1606
 
626
1607
  /** 一键反思:自动取"有日志但无反思"的最早日期,按日志条目生成反思草稿并落盘。 */
@@ -645,6 +1626,7 @@ class MemoryEngine {
645
1626
  }
646
1627
 
647
1628
  // ---------- 维护 ----------
1629
+ /** 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档到 archive/,活跃日志移除。 */
648
1630
  async maintain(days = 30, agent) {
649
1631
  const p = await this.resolvePaths(agent)
650
1632
  const cutoff = new Date()
@@ -655,18 +1637,63 @@ class MemoryEngine {
655
1637
  if (!m) return false
656
1638
  return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) < cutoff
657
1639
  })
658
- if (!oldLogs.length) return '没有超过 ' + days + ' 天的日志,无需归档。'
659
- let archive = '\n## 归档日志(归档于 ' + todayStr() + ')'
660
- let archivedBytes = 0
1640
+ if (!oldLogs.length) return '没有超过 ' + days + ' 天的日志,无需蒸馏。'
1641
+ // 1) AI 蒸馏:提炼有长期价值的要点(不可用则降级为原样归档)
1642
+ let distillText = ''
1643
+ if (this._subagents && agent) {
1644
+ try {
1645
+ const digest = []
1646
+ for (const log of oldLogs) {
1647
+ const text = await this.readTextSafe(path.join(p.projectDir, log.name))
1648
+ if (text && text.trim()) digest.push('### ' + log.name + '\n' + truncateTail(text, 2000))
1649
+ }
1650
+ if (digest.length) {
1651
+ const prompt = [
1652
+ '你是用户的记忆管家。以下日志已超过 ' + days + ' 天,请把它们蒸馏成值得长期记住的要点。',
1653
+ '规则:',
1654
+ '- 只提炼有跨会话长期价值的:技术决策、架构约定、关键路径、用户偏好/习惯、踩过的坑及其规则',
1655
+ '- 丢弃过程流水(某次具体修 bug 的过程、临时路径、搜索结果、报错细节)',
1656
+ '- 按主题组织,可用"### 主题"小标题,每条一句话,简短明确',
1657
+ '- 不要复述日志原文,只要蒸馏后的要点',
1658
+ '输出格式:markdown(### 主题 + - 要点)。若没有任何值得保留的内容,只输出一行:(无)',
1659
+ '',
1660
+ '待蒸馏日志:',
1661
+ digest.join('\n\n'),
1662
+ ].join('\n')
1663
+ const out = await this.runSubagent(prompt, 'auto-memory-distill', agent)
1664
+ if (out && !out.includes('(无)')) distillText = out.trim().slice(0, 3000)
1665
+ }
1666
+ } catch (e) {
1667
+ console.error('[dsh-auto-memory] maintain distill failed', e && e.message ? e.message : e)
1668
+ }
1669
+ }
1670
+ // 2) 原文保底归档到 archive/(不占注入窗口,绝不丢信息)
1671
+ const archiveDir = path.join(p.projectDir, 'archive')
1672
+ await mkdir(archiveDir, { recursive: true })
1673
+ const archived = []
661
1674
  for (const log of oldLogs) {
662
- const text = await this.readTextSafe(path.join(p.projectDir, log.name))
663
- if (text) {
664
- archive += '\n\n### ' + log.name + '\n' + text
665
- archivedBytes += text.length
1675
+ try {
1676
+ const text = await this.readTextSafe(path.join(p.projectDir, log.name))
1677
+ if (text) { await this.writeFull(path.join(archiveDir, log.name), text); archived.push(log.name) }
1678
+ } catch (e) {}
1679
+ }
1680
+ // 3) 蒸馏结果写项目笔记;无 AI 时原样归档段保底
1681
+ let noteMsg = ''
1682
+ if (distillText) {
1683
+ const body = await this.appendText(p.notesPath, '\n## 30 天蒸馏(蒸馏于 ' + this.memToday() + ')\n' + distillText)
1684
+ this.state.notesText = body
1685
+ noteMsg = '\n蒸馏提炼已写入 ' + p.notesPath + ':\n' + truncateTail(distillText, 600)
1686
+ } else if (archived.length) {
1687
+ let archive = '\n## 归档日志(归档于 ' + this.memToday() + ')'
1688
+ for (const name of archived) {
1689
+ const t = await this.readTextSafe(path.join(archiveDir, name))
1690
+ if (t) archive += '\n\n### ' + name + '\n' + t
666
1691
  }
1692
+ const body = await this.appendText(p.notesPath, archive)
1693
+ this.state.notesText = body
1694
+ noteMsg = '\nAI 蒸馏不可用,原文已按老方式归档到 ' + p.notesPath
667
1695
  }
668
- const notesBody = await this.appendText(p.notesPath, archive)
669
- this.state.notesText = notesBody
1696
+ // 4) 活跃目录移除旧日志(原文已在 archive/ 保底)
670
1697
  const deleted = []
671
1698
  const kept = []
672
1699
  for (const log of oldLogs) {
@@ -676,8 +1703,9 @@ class MemoryEngine {
676
1703
  } catch (e) { kept.push(log.name) }
677
1704
  }
678
1705
  this.state.loadedAt = Date.now()
679
- return '已归档 ' + oldLogs.length + ' 个日志文件(' + archivedBytes + ' 字符)' + p.notesPath +
680
- (deleted.length ? '\n已删除: ' + deleted.join(', ') : '') +
1706
+ return '30 天蒸馏完成:' + (distillText ? ' AI 提炼 + 原文归档' : ' 原文归档') + ' ' + oldLogs.length + ' 个旧日志(' + deleted.join(', ') + ')' +
1707
+ '\n原文保底: ' + archiveDir + ' (' + archived.length + ' 个文件)' +
1708
+ noteMsg +
681
1709
  (kept.length ? '\n未删除(可手动清理): ' + kept.join(', ') : '')
682
1710
  }
683
1711
 
@@ -703,6 +1731,7 @@ class MemoryEngine {
703
1731
  todayEntries,
704
1732
  latestReflectionDate: this.state.latestReflectionDate,
705
1733
  pendingReflection: this.state.pendingReflection ? this.state.pendingReflection.date : undefined,
1734
+ autoStats: this.autoStats,
706
1735
  greeting: this.greetingData(),
707
1736
  calendar: this.parseCalendar(this.state.calendarText),
708
1737
  calendarPath: this.state.calendarPath,
@@ -785,12 +1814,12 @@ function isUnderMemoryTree(engine, target) {
785
1814
  // ═══════════════════════════════════════════════════════════════════════════
786
1815
  // 外部记忆接入(其他 AI 工具的记忆继承)
787
1816
  //
788
- // 目标:让 DSH 继承用户在 CodeBuddy / Claude Code / Codex / Cursor 等
789
- // 工具中积累的记忆,持续拟合用户画像。
1817
+ // 目标:让 DSH 继承用户在 AI 助手 / CodeBuddy / Claude Code / Codex /
1818
+ // Cursor 等工具中积累的记忆,持续拟合用户画像。
790
1819
  //
791
1820
  // 源分三类:
792
1821
  // - markdown 记忆(用户级/画像/项目约定):内容小,直接读入缓存,注入/检索/接入
793
- // - 会话日志(jsonl,各工具 projects / sessions):
1822
+ // - 会话日志(jsonl,AI 助手 projects / Claude projects / Codex sessions):
794
1823
  // 只列索引,检索时按需扫描(行数/文件数上限),绝不整库注入
795
1824
  // ═══════════════════════════════════════════════════════════════════════════
796
1825
  class ExternalMemory {
@@ -987,7 +2016,7 @@ class ExternalMemory {
987
2016
  const src = srcs.find((s) => s.id === sourceId)
988
2017
  if (!src) return '外部源不存在: ' + sourceId
989
2018
  if (src.kind === 'sessions') return '会话类源不支持整体接入,请用 memory_recall 按需检索(' + src.files.length + ' 个会话文件)。'
990
- const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + todayStr()
2019
+ const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + this.memToday()
991
2020
  if (target === 'user') {
992
2021
  const p = await engine.resolvePaths(agent)
993
2022
  const body = await engine.appendText(p.userFile, '\n' + stamp + '\n' + src.content.slice(0, 120000))
@@ -1013,6 +2042,33 @@ class ExternalMemory {
1013
2042
  }
1014
2043
  }
1015
2044
 
2045
+ /** 从 agent.session 提取模型可见消息序列 [{role,text,sourceKind}]。 */
2046
+ function extractSessionMessages(agent) {
2047
+ try {
2048
+ const session = agent && agent.session
2049
+ if (!session) return []
2050
+ const nodes = session.surface && session.surface.nodes
2051
+ if (!nodes) return []
2052
+ const seqs = Array.isArray(nodes) ? nodes : (nodes instanceof Set ? Array.from(nodes) : [])
2053
+ if (!seqs.length) return []
2054
+ const events = session.events || []
2055
+ const out = []
2056
+ for (const seq of seqs) {
2057
+ const ev = events[seq]
2058
+ if (!ev) continue
2059
+ let msg = null
2060
+ if (ev.type === 'user/message') msg = ev.data
2061
+ else if (ev.type === 'assistant/message' || ev.type === 'tool/result') msg = ev.data && ev.data.message
2062
+ if (!msg || !msg.role || !Array.isArray(msg.content)) continue
2063
+ const text = msg.content
2064
+ .filter((b) => b && typeof b === 'object' && typeof b.text === 'string')
2065
+ .map((b) => b.text).join('')
2066
+ out.push({ role: msg.role, text, sourceKind: msg.source && msg.source.kind })
2067
+ }
2068
+ return out
2069
+ } catch (e) { return [] }
2070
+ }
2071
+
1016
2072
  /** 递归收集目录下匹配正则的文件(上限 n)。 */
1017
2073
  async function globOne(dir, re, limit) {
1018
2074
  const out = []
@@ -1031,7 +2087,7 @@ async function globOne(dir, re, limit) {
1031
2087
  return out
1032
2088
  }
1033
2089
 
1034
- /** 从一条 jsonl 会话行提取文本片段(兼容 claude/codex 等格式)。 */
2090
+ /** 从一条 jsonl 会话行提取文本片段(兼容 claude/codex/workbuddy 格式)。 */
1035
2091
  function extractJsonText(line) {
1036
2092
  try {
1037
2093
  const obj = JSON.parse(line)
@@ -1068,11 +2124,38 @@ export function apply(ctx, config) {
1068
2124
  // 生命周期刷新
1069
2125
  const refreshAll = (agent) => { void engine.refresh(agent) }
1070
2126
  refreshAll()
1071
- ctx.on('agent/session-start', (payload) => refreshAll(payload && payload.agent))
2127
+ // 自动检查更新:host 启动时查一次 npm registry(结果缓存 12 小时,设置页打开直接读缓存显示)
2128
+ void engine.checkUpdate(false)
2129
+ ctx.on('agent/session-start', (payload) => {
2130
+ refreshAll(payload && payload.agent)
2131
+ // 缓存最近 agent 引用(subagent parent 需要完整 agent 对象)
2132
+ try { if (payload && payload.agent) engine._lastAgent = payload.agent } catch (e) {}
2133
+ })
1072
2134
  ctx.on('agent/turn-stopping', (payload) => {
1073
2135
  refreshAll(payload && payload.agent)
1074
2136
  // 记录最后活动时间(判断暂离回来用)
1075
2137
  try { engine._lastActiveAt = Date.now() } catch (e) {}
2138
+ // 每轮自动沉淀:取本轮消息 → subagent 判断/提炼 → 写今日日志([自动沉淀])+升格长期记忆
2139
+ // 注意:turn-stopping payload 只有 {turn, signal},没有 agent 字段,必须用 _lastAgent 兜底
2140
+ try {
2141
+ const agent = (payload && payload.agent) || engine._lastAgent
2142
+ if (agent && agent.session) void engine.consolidateTurn(payload.turn, agent)
2143
+ } catch (e) {}
2144
+ })
2145
+ // 注入层保障:systemPrompt section.text 是同步函数不能 await,state 异步加载会导致首轮注入为空。
2146
+ // pre-step 是 waterfall(可 await),在放行每个 step 前条件性刷新:
2147
+ // 首轮(loadedAt=0)必定 await 完 → 模型从第一个 token 起就看到记忆;之后每 15s 跟进轮间新写入。
2148
+ ctx.on('agent/pre-step', async (payload, next) => {
2149
+ try {
2150
+ const agent = (payload && payload.agent) || engine._lastAgent
2151
+ // 只刷新顶层会话:子代理(自动沉淀/固化的 subagent)session 无 cwd,刷新会把 state 切到错误工作区
2152
+ let skip = false
2153
+ try { if (agent && agent.session && agent.session.header && agent.session.header.parentSession) skip = true } catch (e) {}
2154
+ if (!skip && agent && (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 15000)) {
2155
+ await engine.refresh(agent)
2156
+ }
2157
+ } catch (e) {}
2158
+ return next()
1076
2159
  })
1077
2160
 
1078
2161
  // ---------- 系统提示词注入 ----------
@@ -1083,7 +2166,7 @@ export function apply(ctx, config) {
1083
2166
  try {
1084
2167
  const agent = context && context.agent
1085
2168
  if (!agent) return ''
1086
- if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 60000) {
2169
+ if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 15000) {
1087
2170
  void engine.refresh(agent)
1088
2171
  }
1089
2172
  return engine.renderMemory(context) + engine.renderReflectionRequest()
@@ -1097,57 +2180,61 @@ export function apply(ctx, config) {
1097
2180
  note: { type: 'string', required: true, description: '简短条目:一句话概括做了什么、结果如何。' },
1098
2181
  date: { type: 'string', description: '日志日期 YYYY-MM-DD,缺省今天。' },
1099
2182
  }, async (args, exec) => {
1100
- const date = DATE_RE.test(args.date || '') ? args.date : todayStr()
2183
+ const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
1101
2184
  const p = await engine.resolvePaths(exec.agent)
1102
2185
  const logPath = path.join(p.projectDir, date + '.md')
1103
2186
  const entry = '- ' + nowHm() + ' ' + String(args.note).trim()
1104
2187
  const body = await engine.appendText(logPath, entry)
1105
- if (date === todayStr()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
2188
+ if (date === engine.memToday()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
1106
2189
  return '已更新记忆文档: ' + logPath + '\n' + entry
1107
2190
  }),
1108
2191
 
1109
- defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点)。action=append 追加一段(自动带日期标题);action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容)。**调用后必须在本轮回复正文中向用户转述:更新了项目笔记、加入什么要点**。', {
2192
+ defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点)。action=append 追加一段(自动带日期标题);action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容)。每日预算 3000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:更新了项目笔记、加入什么要点**。', {
1110
2193
  content: { type: 'string', required: true, description: '笔记内容。' },
1111
2194
  action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
1112
2195
  }, async (args, exec) => {
1113
2196
  const p = await engine.resolvePaths(exec.agent)
1114
2197
  const content = String(args.content || '').trim()
1115
2198
  if (!content) return 'memory_note: content 为空,未写入。'
2199
+ const acct = await engine.ensureBudget(exec.agent, 'note', content)
2200
+ if (!acct.ok) return 'memory_note: 项目笔记今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
1116
2201
  let body
1117
2202
  if (args.action === 'replace') {
1118
2203
  body = content
1119
2204
  await engine.writeFull(p.notesPath, body)
1120
2205
  } else {
1121
- body = await engine.appendText(p.notesPath, '\n## ' + todayStr() + '\n' + content)
2206
+ body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + content)
1122
2207
  }
1123
2208
  engine.state.notesText = body; engine.state.loadedAt = Date.now()
1124
- return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + content
2209
+ return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
1125
2210
  }),
1126
2211
 
1127
- defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
2212
+ defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。每日预算 4000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
1128
2213
  content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
1129
2214
  action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
1130
2215
  }, async (args, exec) => {
1131
2216
  const p = await engine.resolvePaths(exec.agent)
1132
2217
  const content = String(args.content || '').trim()
1133
2218
  if (!content) return 'memory_user: content 为空,未写入。'
2219
+ const acct = await engine.ensureBudget(exec.agent, 'user', content)
2220
+ if (!acct.ok) return 'memory_user: 用户级记忆今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
1134
2221
  let body
1135
2222
  if (args.action === 'replace') {
1136
2223
  body = content
1137
2224
  await engine.writeFull(p.userFile, body)
1138
2225
  } else {
1139
- body = await engine.appendText(p.userFile, '\n## ' + todayStr() + '\n' + content)
2226
+ body = await engine.appendText(p.userFile, '\n## ' + engine.memToday() + '\n' + content)
1140
2227
  }
1141
2228
  engine.state.userText = body; engine.state.loadedAt = Date.now()
1142
- return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content
2229
+ return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
1143
2230
  }),
1144
2231
 
1145
- defineTool('memory_recall', '检索记忆:本地记忆文件(每日日志、项目笔记、用户级记忆、反思)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。用户提到过去的做法/讨论/决定而当前上下文没有时调用,查询必须自包含。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
2232
+ defineTool('memory_recall', '检索记忆:本地记忆文件(当前工作区 + 其他所有工作区的每日日志、项目笔记、用户级记忆、反思)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。开发/排查中不懂的、用户提到过去的做法/讨论/决定而当前上下文没有时调用——跨工作区的记忆也能检索到(结果标注来源工作区)。查询必须自包含。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
1146
2233
  query: { type: 'string', required: true, description: '检索关键词或自包含描述。' },
1147
2234
  limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
1148
2235
  }, async (args, exec) => engine.recall(args.query, args.limit, exec.agent)),
1149
2236
 
1150
- defineTool('memory_maintain', '维护记忆:把 days(缺省30)天前的 .dsh-memory/ 每日日志原样归档进项目 MEMORY.md 的归档段,并删除旧日志文件。归档保底不丢信息,之后可按返回结果决定是否精简归档段。', {
2237
+ defineTool('memory_maintain', '维护记忆(30 天蒸馏):把 days(缺省30)天前的 .dsh-memory/ 每日日志交给 AI 蒸馏提炼出有长期价值的要点写入项目 MEMORY.md,原文保底归档到 .dsh-memory/archive/ 后从活跃日志移除。AI 不可用时降级为原样归档,不丢信息。', {
1151
2238
  days: { type: 'integer', description: '归档阈值天数,缺省 30。' },
1152
2239
  }, async (args, exec) => engine.maintain(args.days, exec.agent)),
1153
2240
 
@@ -1168,14 +2255,14 @@ export function apply(ctx, config) {
1168
2255
  text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
1169
2256
  }, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
1170
2257
 
1171
- defineTool('memory_external', '查看/接入其他 AI 工具(CodeBuddy/Claude Code/Codex/项目约定文件等)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 把某源内容整体接入本地记忆(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,自动标注来源)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
2258
+ defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 把某源内容整体接入本地记忆(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,自动标注来源)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
1172
2259
  action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
1173
2260
  source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
1174
2261
  target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
1175
2262
  }, async (args, exec) => {
1176
2263
  if (args.action === 'list') {
1177
2264
  const list = await engine.external.summarize()
1178
- if (!list.length) return '未检测到其他 AI 工具的记忆文件(可检查 ~/.codebuddy、~/.claude、~/.codex 等目录是否存在)。'
2265
+ if (!list.length) return '未检测到其他 AI 工具的记忆文件(可检查 ~/.workbuddy、~/.codebuddy、~/.claude、~/.codex 是否存在)。'
1179
2266
  const lines = []
1180
2267
  lines.push('检测到 ' + list.length + ' 个外部记忆源:')
1181
2268
  for (const s of list) {
@@ -1222,6 +2309,10 @@ export function apply(ctx, config) {
1222
2309
  time: { type: 'string', required: true, description: '时间 HH:MM 或 --:--。' },
1223
2310
  title: { type: 'string', required: true, description: '事项标题。' },
1224
2311
  }, async (args, exec) => engine.calendarRemove(args.date, args.time, args.title, exec.agent)),
2312
+
2313
+ defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。适合隔一段时间主动调用一次;每轮对话结束的自动沉淀也基于同一套提炼逻辑。', {
2314
+ days: { type: 'integer', description: '读取最近 N 天日志,缺省 7,上限 30。' },
2315
+ }, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
1225
2316
  ]
1226
2317
 
1227
2318
  // ---------- 路由 ----------
@@ -1232,7 +2323,13 @@ export function apply(ctx, config) {
1232
2323
  handler: async (req, res) => {
1233
2324
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
1234
2325
  if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
1235
- try { writeJson(res, 200, await engine.snapshot()) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2326
+ try {
2327
+ // 优先用前端传来的当前工作区(客户端从 ctx.sessions.list 拿),回退最近活跃 agent
2328
+ const url = new URL(req.url || '/', 'http://localhost')
2329
+ const ws = url.searchParams.get('ws') || ''
2330
+ await engine.refresh(ws ? { session: { header: { cwd: ws } } } : engine._lastAgent)
2331
+ writeJson(res, 200, await engine.snapshot())
2332
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
1236
2333
  },
1237
2334
  },
1238
2335
  {
@@ -1287,6 +2384,125 @@ export function apply(ctx, config) {
1287
2384
  try { writeJson(res, 200, { result: await engine.recall(body.query, body.limit) }) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
1288
2385
  },
1289
2386
  },
2387
+ {
2388
+ kind: 'exact',
2389
+ path: API.smartRecall,
2390
+ handler: async (req, res) => {
2391
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2392
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2393
+ const body = await readJsonBody(req)
2394
+ if (!body || typeof body.query !== 'string') return writeJson(res, 400, { error: 'invalid body' })
2395
+ try {
2396
+ await engine.refresh(undefined)
2397
+ writeJson(res, 200, await engine.smartRecall(body.query))
2398
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2399
+ },
2400
+ },
2401
+ {
2402
+ kind: 'exact',
2403
+ path: API.workspaces,
2404
+ handler: async (req, res) => {
2405
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2406
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2407
+ const body = await readJsonBody(req).catch(() => ({}))
2408
+ try {
2409
+ writeJson(res, 200, await engine.workspaceOverview(undefined, !!(body && body.force)))
2410
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2411
+ },
2412
+ },
2413
+ {
2414
+ kind: 'exact',
2415
+ path: API.debug,
2416
+ handler: async (req, res) => {
2417
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2418
+ if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
2419
+ try { writeJson(res, 200, await engine.debugInfo()) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2420
+ },
2421
+ },
2422
+ {
2423
+ kind: 'exact',
2424
+ path: API.browseDir,
2425
+ handler: async (req, res) => {
2426
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2427
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2428
+ const body = await readJsonBody(req).catch(() => ({}))
2429
+ try {
2430
+ let p = String((body && body.path) || homedir() || process.cwd())
2431
+ if (p === '~') p = homedir()
2432
+ if (p.startsWith('~/') || p.startsWith('~\\')) p = path.join(homedir(), p.slice(2))
2433
+ const st = await stat(p)
2434
+ if (!st.isDirectory()) p = path.dirname(p)
2435
+ const entries = await readdir(p, { withFileTypes: true })
2436
+ const dirs = entries
2437
+ .filter((en) => en.isDirectory() && !en.name.startsWith('.'))
2438
+ .map((en) => ({ name: en.name, path: path.join(p, en.name) }))
2439
+ .sort((a, b) => (a.name < b.name ? -1 : 1))
2440
+ .slice(0, 300)
2441
+ writeJson(res, 200, { path: p, parent: path.dirname(p), dirs })
2442
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2443
+ },
2444
+ },
2445
+ {
2446
+ kind: 'exact',
2447
+ path: API.pickDir,
2448
+ handler: async (req, res) => {
2449
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2450
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2451
+ // 调用系统的文件夹选择器(native 后端,由 dsh 的 directory-picker-auto 按主机情况挂载);不可用则返回 native:false,前端回退内嵌浏览
2452
+ try {
2453
+ let picker = undefined
2454
+ try { picker = ctx.directoryPicker } catch (e) {}
2455
+ if (!picker || typeof picker.capability !== 'function') return writeJson(res, 200, { native: false })
2456
+ const cap = picker.capability()
2457
+ if (!cap || cap.kind !== 'native' || typeof cap.pick !== 'function') return writeJson(res, 200, { native: false })
2458
+ const ac = new AbortController()
2459
+ const timer = setTimeout(() => { try { ac.abort() } catch (e) {} }, 5 * 60 * 1000)
2460
+ try {
2461
+ const dir = await cap.pick(ac.signal)
2462
+ writeJson(res, 200, { native: true, dir: dir || null })
2463
+ } finally { clearTimeout(timer) }
2464
+ } catch (e) { writeJson(res, 200, { native: true, dir: null, error: String(e && e.message ? e.message : e) }) }
2465
+ },
2466
+ },
2467
+ {
2468
+ kind: 'exact',
2469
+ path: API.updateCheck,
2470
+ handler: async (req, res) => {
2471
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2472
+ if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
2473
+ try {
2474
+ const url = new URL(req.url || '/', 'http://localhost')
2475
+ const force = url.searchParams.get('force') === '1'
2476
+ writeJson(res, 200, await engine.checkUpdate(force))
2477
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2478
+ },
2479
+ },
2480
+ {
2481
+ kind: 'exact',
2482
+ path: API.update,
2483
+ handler: async (req, res) => {
2484
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2485
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2486
+ // 一键更新:在 profile 目录执行 pnpm up / npm install,完成后提示重启
2487
+ try {
2488
+ const prof = await engine.findProfileDir()
2489
+ if (!prof) return writeJson(res, 200, { ok: false, message: '未找到 dsh profile 目录(未安装插件或已删除)。' })
2490
+ if (prof.installKind === 'dev-link') return writeJson(res, 200, { ok: false, message: '当前为本地开发链接(link:)安装,不适用 npm 更新,请直接同步开发源码。' })
2491
+ if (prof.installKind !== 'registry') return writeJson(res, 200, { ok: false, message: '未检测到 registry 安装方式,无法自动更新。' })
2492
+ const cmd = prof.usesPnpm ? 'pnpm up @a9i5k4/dsh-auto-memory' : 'npm install @a9i5k4/dsh-auto-memory@latest'
2493
+ const out = await execP(cmd, { cwd: prof.dir, timeout: 120000, windowsHide: true })
2494
+ const tail = ((out.stdout || '') + (out.stderr || '')).trim().slice(-1200)
2495
+ writeJson(res, 200, {
2496
+ ok: true,
2497
+ output: tail || '(无输出)',
2498
+ restartHint: '更新完成,请重启 dsh web 生效。',
2499
+ installKind: prof.installKind,
2500
+ })
2501
+ } catch (e) {
2502
+ writeJson(res, 200, { ok: false, message: String(e && e.message ? e.message : e) })
2503
+ }
2504
+ },
2505
+ },
1290
2506
  {
1291
2507
  kind: 'exact',
1292
2508
  path: API.config,
@@ -1304,7 +2520,8 @@ export function apply(ctx, config) {
1304
2520
  const allowed = Object.keys(DEFAULT_CONFIG)
1305
2521
  const patch = {}
1306
2522
  for (const key of allowed) if (body[key] !== undefined) patch[key] = body[key]
1307
- writeJson(res, 200, { config: await engine.saveConfig(patch) })
2523
+ const saved = await engine.saveConfig(patch)
2524
+ writeJson(res, 200, { config: saved.config, migrated: saved.migrated || '' })
1308
2525
  return
1309
2526
  }
1310
2527
  writeJson(res, 405, { error: 'method not allowed' })
@@ -1321,7 +2538,7 @@ export function apply(ctx, config) {
1321
2538
  if (!body || typeof body.content !== 'string' || !body.content.trim()) return writeJson(res, 400, { error: 'invalid body' })
1322
2539
  try {
1323
2540
  const p = await engine.resolvePaths(undefined)
1324
- const text = '\n## ' + todayStr() + '\n' + body.content.trim()
2541
+ const text = '\n## ' + engine.memToday() + '\n' + body.content.trim()
1325
2542
  const updated = await engine.appendText(p.notesPath, text)
1326
2543
  engine.state.notesText = updated
1327
2544
  engine.state.loadedAt = Date.now()
@@ -1404,17 +2621,69 @@ export function apply(ctx, config) {
1404
2621
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
1405
2622
  const body = await readJsonBody(req)
1406
2623
  if (!body || typeof body.period !== 'string') return writeJson(res, 400, { error: 'invalid body' })
1407
- try { writeJson(res, 200, { result: await engine.summarizePeriod(body.period) }) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2624
+ try {
2625
+ const ws = body.ws || ''
2626
+ await engine.refresh(ws ? { session: { header: { cwd: ws } } } : undefined)
2627
+ const out = await engine.summarizePeriod(body.period, undefined, !!body.force)
2628
+ writeJson(res, 200, { ...out, result: out.summary })
2629
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2630
+ },
2631
+ },
2632
+ {
2633
+ kind: 'exact',
2634
+ path: API.greet,
2635
+ handler: async (req, res) => {
2636
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2637
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2638
+ try {
2639
+ const ws = body && body.ws ? body.ws : ''
2640
+ await engine.refresh(ws ? { session: { header: { cwd: ws } } } : undefined)
2641
+ writeJson(res, 200, await engine.greetToday())
2642
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
1408
2643
  },
1409
2644
  },
1410
2645
  ]
1411
2646
 
2647
+ // ---------- 后台轮询兜底(对标外部助手的心跳轮询):每 5 分钟重试失败的自动沉淀 + 心跳文件 ----------
2648
+ // 心跳:每次轮询把存活状态写入 ~/.dsh/memory/polling-heartbeat.json(可随时查看 LastWriteTime 确认轮询活着)
2649
+ const writeHeartbeat = async () => {
2650
+ try {
2651
+ const q = engine._pendingConsolidations || []
2652
+ const hb = path.join(dshHome(), 'memory', 'polling-heartbeat.json')
2653
+ await mkdir(path.dirname(hb), { recursive: true })
2654
+ await writeFile(hb, JSON.stringify({
2655
+ pid: process.pid,
2656
+ heartbeatAt: Date.now(),
2657
+ uptimeMs: Math.round(process.uptime() * 1000),
2658
+ queueLength: q.length,
2659
+ lastConsolidatedAt: (engine.autoStats && engine.autoStats.lastAt) || 0,
2660
+ todayCount: (engine.autoStats && engine.autoStats.count) || 0,
2661
+ }, null, 2), 'utf8')
2662
+ } catch (e) {}
2663
+ }
2664
+ const retryTimer = setInterval(() => {
2665
+ void (async () => {
2666
+ try {
2667
+ const q = engine._pendingConsolidations
2668
+ if (q && q.length && !engine._consolidating) {
2669
+ const item = q.shift()
2670
+ if (item && item.agent) void engine.consolidateTurn(item.turn, item.agent)
2671
+ }
2672
+ } catch (e) {}
2673
+ })()
2674
+ }, 5 * 60 * 1000)
2675
+ // 心跳独立 15 秒一次(对齐 polling-lease 心跳节奏),证明轮询机制活着
2676
+ const heartbeatTimer = setInterval(() => { void writeHeartbeat() }, 15000)
2677
+ void writeHeartbeat() // 立即心跳一次:重启后马上可见轮询存活
2678
+
1412
2679
  // ---------- 注册与清理 ----------
1413
2680
  const disposers = []
1414
2681
  disposers.push(disposeSection)
1415
2682
  for (const tool of tools) disposers.push(ctx.tools.register(tool))
1416
2683
  for (const route of routes) disposers.push(ctx.webServer.register(route))
1417
2684
  ctx.effect(() => () => {
2685
+ clearInterval(retryTimer)
2686
+ clearInterval(heartbeatTimer)
1418
2687
  for (const dispose of disposers) { try { dispose() } catch (e) {} }
1419
2688
  }, 'dsh-auto-memory: surfaces')
1420
2689