@a9i5k4/dsh-auto-memory 0.1.8 → 0.1.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.
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,17 @@
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'
20
22
  import { homedir } from 'node:os'
21
23
  import path from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
22
25
 
23
26
  /** Stable cordis plugin name. */
24
27
  export const name = 'auto-memory'
@@ -26,11 +29,11 @@ export const name = 'auto-memory'
26
29
  /** Services required before the memory surfaces can mount. */
27
30
  export const inject = ['webServer', 'tools', 'systemPrompt', 'subagents']
28
31
 
29
- /** Prompt order of the memory section (before the tool-guidance band 100+). */
30
- const SECTION_ORDER = 90
32
+ /** Prompt order of the memory section. 10000 = 末尾注入(紧跟用户消息,recency 最高,保证记忆纪律/自动沉淀说明被模型最后读到,遵循度更高) */
33
+ const SECTION_ORDER = 10000
31
34
 
32
35
  /** 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 的记忆」时即指本插件,请据此协作。'
36
+ 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
37
 
35
38
  /** Route family. */
36
39
  export const API = {
@@ -38,6 +41,11 @@ export const API = {
38
41
  list: '/api/dsh-auto-memory/list',
39
42
  file: '/api/dsh-auto-memory/file',
40
43
  recall: '/api/dsh-auto-memory/recall',
44
+ smartRecall: '/api/dsh-auto-memory/smart-recall',
45
+ workspaces: '/api/dsh-auto-memory/workspaces',
46
+ debug: '/api/dsh-auto-memory/debug',
47
+ browseDir: '/api/dsh-auto-memory/browse-dir',
48
+ pickDir: '/api/dsh-auto-memory/pick-dir',
41
49
  config: '/api/dsh-auto-memory/config',
42
50
  reflect: '/api/dsh-auto-memory/reflect',
43
51
  'reflect-auto': '/api/dsh-auto-memory/reflect-auto',
@@ -46,6 +54,7 @@ export const API = {
46
54
  'external-import': '/api/dsh-auto-memory/external-import',
47
55
  calendar: '/api/dsh-auto-memory/calendar',
48
56
  summarize: '/api/dsh-auto-memory/summarize',
57
+ greet: '/api/dsh-auto-memory/greet',
49
58
  }
50
59
 
51
60
  const DEFAULT_CONFIG = {
@@ -53,12 +62,20 @@ const DEFAULT_CONFIG = {
53
62
  userMemoryDir: '~/.dsh/memory',
54
63
  /** 项目级记忆目录名(相对工作区)。 */
55
64
  projectMemoryDir: '.dsh-memory',
65
+ /** 集中式记忆根目录(集中式):所有工作区的记忆统一存放,每工作区一个子目录(旧版分散在各工作区 .dsh-memory/ 会自动迁移)。 */
66
+ memoryRoot: '~/.dsh/memory/workspaces',
56
67
  /** 是否注入记忆上下文。 */
57
68
  injectEnabled: true,
58
69
  /** 注入总预算(字符)。 */
59
70
  injectBudgetChars: 2400,
60
71
  /** 注入的最近日志天数。 */
61
72
  recentDaysInjected: 3,
73
+ /** 每轮对话结束自动沉淀记忆(subagent 判断+提炼,有 API 成本;默认开)。 */
74
+ autoConsolidate: true,
75
+ /** 自动沉淀内容门槛:本轮 user+assistant 文本总字符数低于此值视为寒暄,跳过。 */
76
+ autoConsolidateMinChars: 60,
77
+ /** 日界(分钟,从 0 点起算):凌晨在此之前的活儿归前一天日志;默认 450=早上 7:30 后才进入新一天。 */
78
+ dayBoundaryMinutes: 450,
62
79
  /** 是否启用每日反思。 */
63
80
  reflectEnabled: true,
64
81
  /** 反思风格: auto=由内容决定 / life=生活化 / professional=专业性。 */
@@ -67,7 +84,7 @@ const DEFAULT_CONFIG = {
67
84
  locale: 'zh',
68
85
  /** 外部记忆注入预算(字符)。 */
69
86
  externalInjectionChars: 1400,
70
- /** 外部记忆源开关(CodeBuddy/Claude Code/Codex/项目约定等)。 */
87
+ /** 外部记忆源开关(AI 助手/CodeBuddy/Claude Code/Codex/项目约定)。 */
71
88
  externalSources: {
72
89
  'workbuddy-user': true,
73
90
  'workbuddy-profile': true,
@@ -84,6 +101,7 @@ const DEFAULT_CONFIG = {
84
101
  const pad = (n) => String(n).padStart(2, '0')
85
102
  const todayStr = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
86
103
  const nowHm = () => { const d = new Date(); return `${pad(d.getHours())}:${pad(d.getMinutes())}` }
104
+ const nowHms = () => { const d = new Date(); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` }
87
105
  const dateStrOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
88
106
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
89
107
  const truncateHead = (s, n) => (s && s.length > n) ? s.slice(0, n) + '\n…(截断,完整内容用 memory_recall 或 GUI 面板)' : (s || '')
@@ -114,6 +132,11 @@ class MemoryEngine {
114
132
  }
115
133
  this._configPath = path.join(dshHome(), 'dsh-auto-memory.json')
116
134
  this._readError = undefined
135
+ this._lastAgent = undefined // 最近一次 agent 引用(subagent parent 需要完整 agent 对象)
136
+ this._lastTurnByAgent = undefined // Map<agentId, turn>:自动沉淀去重(每轮只写一次)
137
+ this._consolidating = undefined // 自动沉淀进行中标记(防重入)
138
+ this._budgets = undefined // 每日写入预算:用户级4000/项目级3000字/天(所有会话共享,跨天重置)
139
+ this.autoStats = { count: 0, lastAt: 0, lastText: '', lastDate: '' } // 自动沉淀统计(GUI 即时反馈)
117
140
  this.external = new ExternalMemory(this)
118
141
  }
119
142
 
@@ -133,12 +156,48 @@ class MemoryEngine {
133
156
 
134
157
  async saveConfig(patch) {
135
158
  await this.loadConfig()
159
+ const oldRoot = this.expandUserPath(this.config.memoryRoot)
160
+ const oldUser = this.expandUserPath(this.config.userMemoryDir)
136
161
  this.config = { ...this.config, ...patch }
162
+ const newRoot = this.expandUserPath(this.config.memoryRoot)
163
+ const newUser = this.expandUserPath(this.config.userMemoryDir)
164
+ // 换存放位置时自动迁移旧文件(旧文件保留不删,新位置缺啥补啥),所有路径变量在下方 refresh 后全部跟随新配置
165
+ let migrated = ''
166
+ try {
167
+ if (oldRoot && newRoot && oldRoot !== newRoot) {
168
+ const olds = await readdir(oldRoot, { withFileTypes: true }).catch(() => [])
169
+ let n = 0
170
+ for (const en of olds) {
171
+ if (!en.isDirectory()) continue
172
+ const src = path.join(oldRoot, en.name)
173
+ const dst = path.join(newRoot, en.name)
174
+ try {
175
+ const exists = await stat(dst).catch(() => null)
176
+ if (!exists) { await this.copyDir(src, dst); n++ }
177
+ } catch (e) {}
178
+ }
179
+ if (n) migrated = '已把旧位置 ' + n + ' 个工作区记忆迁移到新根(旧文件保留未删)。'
180
+ }
181
+ if (oldUser && newUser && oldUser !== newUser) {
182
+ const olds = await readdir(oldUser, { withFileTypes: true }).catch(() => [])
183
+ let n = 0
184
+ for (const en of olds) {
185
+ if (!en.isFile()) continue
186
+ const src = path.join(oldUser, en.name)
187
+ const dst = path.join(newUser, en.name)
188
+ try {
189
+ const exists = await stat(dst).catch(() => null)
190
+ if (!exists) { await copyFile(src, dst); n++ }
191
+ } catch (e) {}
192
+ }
193
+ if (n) migrated += (migrated ? ' ' : '') + '已把用户级记忆 ' + n + ' 个文件迁移到新目录(旧文件保留未删)。'
194
+ }
195
+ } catch (e) { console.error('[dsh-auto-memory] config migrate failed', e) }
137
196
  await mkdir(path.dirname(this._configPath), { recursive: true })
138
197
  await writeFile(this._configPath, JSON.stringify(this.config, null, 2), 'utf8')
139
198
  this.state.loadedAt = 0 // 强制重载(目录可能变化)
140
199
  await this.refresh(undefined)
141
- return this.config
200
+ return { config: this.config, migrated }
142
201
  }
143
202
 
144
203
  // ---------- 路径 ----------
@@ -149,9 +208,46 @@ class MemoryEngine {
149
208
  return path.resolve(p)
150
209
  }
151
210
 
211
+ /** 工作区路径 → 记忆子目录名(与 ~/.dsh/sessions 目录风格一致,可读且唯一)。 */
212
+ wsKey(ws) {
213
+ if (!ws) return 'default'
214
+ return '--' + String(ws).replace(/[\\/:*?"<>|]/g, '-') + '--'
215
+ }
216
+
217
+ /** 集中式记忆根目录(集中式):所有工作区记忆统一存放,每工作区一个子目录。 */
152
218
  projectDirOf(ws) {
153
219
  const name = this.config.projectMemoryDir || '.dsh-memory'
154
- return path.isAbsolute(name) ? name : path.join(ws || process.cwd(), name)
220
+ if (path.isAbsolute(name)) return name // 旧用法:绝对路径兼容
221
+ const root = this.expandUserPath(this.config.memoryRoot) || path.join(dshHome(), 'memory', 'workspaces')
222
+ return path.join(root, this.wsKey(ws))
223
+ }
224
+
225
+ /** 递归复制目录(迁移用)。 */
226
+ async copyDir(src, dst) {
227
+ await mkdir(dst, { recursive: true })
228
+ const entries = await readdir(src, { withFileTypes: true })
229
+ for (const en of entries) {
230
+ const s = path.join(src, en.name)
231
+ const d = path.join(dst, en.name)
232
+ if (en.isDirectory()) await this.copyDir(s, d)
233
+ else if (en.isFile()) await copyFile(s, d)
234
+ }
235
+ }
236
+
237
+ /** 旧版分散结构({ws}/.dsh-memory) → 集中式根目录 自动迁移(复制,不删旧,安全)。 */
238
+ async migrateLegacy(ws, projectDir) {
239
+ try {
240
+ if (!ws) return
241
+ const legacy = path.join(ws, '.dsh-memory')
242
+ let legacyOk = false
243
+ try { legacyOk = (await stat(legacy)).isDirectory() } catch (e) {}
244
+ if (!legacyOk) return
245
+ let targetOk = false
246
+ try { targetOk = (await stat(projectDir)).isDirectory() } catch (e) {}
247
+ if (targetOk) return
248
+ await this.copyDir(legacy, projectDir)
249
+ console.log('[dsh-auto-memory] migrated memory: ' + legacy + ' -> ' + projectDir)
250
+ } catch (e) { console.error('[dsh-auto-memory] migrate failed', e) }
155
251
  }
156
252
 
157
253
  userDirOf() {
@@ -171,10 +267,11 @@ class MemoryEngine {
171
267
  calendarPath: path.join(userDir, 'CALENDAR.md'),
172
268
  projectDir,
173
269
  notesPath: path.join(projectDir, 'MEMORY.md'),
174
- logPath: path.join(projectDir, `${todayStr()}.md`),
270
+ logPath: path.join(projectDir, `${this.memToday()}.md`),
175
271
  reflectDir: path.join(projectDir, 'reflections'),
176
272
  greetDir: path.join(projectDir, 'greetings'),
177
- greetPath: path.join(projectDir, 'greetings', `${todayStr()}.md`),
273
+ greetPath: path.join(projectDir, 'greetings', `${this.memToday()}.json`),
274
+ greetPathLegacy: path.join(projectDir, 'greetings', `${this.memToday()}.md`),
178
275
  }
179
276
  }
180
277
 
@@ -231,7 +328,7 @@ class MemoryEngine {
231
328
  const logs = await this.listDailyLogs(projectDir, 30)
232
329
  const reflections = await this.listReflections(reflectDir, 30)
233
330
  const done = new Set(reflections.map((r) => r.date))
234
- const today = todayStr()
331
+ const today = this.memToday()
235
332
  for (const log of logs) {
236
333
  if (log.date >= today) continue
237
334
  if (done.has(log.date)) continue
@@ -257,6 +354,8 @@ class MemoryEngine {
257
354
  try {
258
355
  if (!this.configLoaded) await this.loadConfig()
259
356
  const p = await this.resolvePaths(agent)
357
+ // 旧版分散记忆({ws}/.dsh-memory) → 集中式根目录 自动迁移
358
+ await this.migrateLegacy(p.ws, p.projectDir)
260
359
  this.state.ws = p.ws
261
360
  this.state.userDir = p.userDir
262
361
  this.state.projectDir = p.projectDir
@@ -282,19 +381,160 @@ class MemoryEngine {
282
381
  const pending = await this.detectPendingReflection(p.projectDir, p.reflectDir)
283
382
  if (pending) this.state.pendingReflection = pending
284
383
  }
285
- // 今日拟人化问候(每天首会话展示一次)
286
- this.state.todayGreeting = await this.readTextSafe(p.greetPath)
384
+ // 今日拟人化问候(每天首会话展示一次;新 .json 优先,旧 .md 兼容)
385
+ this.state.todayGreeting = (await this.readTextSafe(p.greetPath)) || (await this.readTextSafe(p.greetPathLegacy))
287
386
  // 日历/日程(用户级,跨工作区与重装保留)
288
387
  this.state.calendarPath = p.calendarPath
289
388
  this.state.calendarText = await this.readTextSafe(p.calendarPath)
290
389
  // 外部记忆探测(后台,结果进缓存)
291
390
  if (this.config.externalSources) void this.external.discover(true)
391
+ // 记忆地图:其他工作区(名称+最近日志日期),供注入引导跨区检索
392
+ try {
393
+ const map = []
394
+ // 工作区发现 5 分钟缓存(周期刷新每 15s 触发一次,全量扫描 sessions 太贵)
395
+ let cwds
396
+ if (this._wsCache && Date.now() - this._wsCache.at < 5 * 60 * 1000) {
397
+ cwds = this._wsCache.list
398
+ } else {
399
+ cwds = await this.discoverWorkspaces()
400
+ this._wsCache = { at: Date.now(), list: cwds }
401
+ }
402
+ for (const cwd of cwds) {
403
+ if (cwd === p.ws) continue
404
+ const p2 = this.projectDirOf(cwd)
405
+ if (p2 === p.projectDir) continue
406
+ const logs2 = await this.listDailyLogs(p2, 1)
407
+ if (!logs2.length) continue
408
+ const name = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
409
+ map.push(name + '(最近日志 ' + logs2[0].date + ')')
410
+ }
411
+ this.state.workspaceMap = map
412
+ } catch (e) { this.state.workspaceMap = [] }
292
413
  this.state.loadedAt = Date.now()
293
414
  } catch (e) {
294
415
  console.error('[dsh-auto-memory] refresh failed', e)
295
416
  }
296
417
  }
297
418
 
419
+ /** 记忆日:日界(默认 7:30)前把凌晨归到前一天;日界后进入新一天。日志/沉淀/反思/问候/预算都按它切日。 */
420
+ memToday() {
421
+ const b = Number(this.config.dayBoundaryMinutes)
422
+ const boundary = Number.isFinite(b) && b >= 0 ? b : 450
423
+ const d = new Date()
424
+ if (d.getHours() * 60 + d.getMinutes() < boundary) d.setDate(d.getDate() - 1)
425
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
426
+ }
427
+
428
+ // ---------- 每日写入预算 + 超限自动压缩(硬约束,但不拒绝新内容) ----------
429
+ /**
430
+ * 用户级 ≤4000 字/天、项目级 ≤3000 字/天(所有会话共享一天预算,跨天重置)。
431
+ * 超限时不直接拒绝:先自动压缩"今天之前"的旧内容腾出空间(ensureBudget),压缩不可用时才拒绝。
432
+ */
433
+ accountWrite(agent, layer, text) {
434
+ if (!this._budgets) this._budgets = new Map()
435
+ const today = this.memToday()
436
+ let b = this._budgets.get('today')
437
+ if (!b || b.date !== today) { b = { date: today, user: 0, note: 0 }; this._budgets.set('today', b) }
438
+ const limit = layer === 'user' ? 4000 : 3000
439
+ const next = b[layer] + String(text || '').length
440
+ if (next > limit) return { ok: false, used: b[layer], limit, remaining: Math.max(0, limit - b[layer]) }
441
+ b[layer] = next
442
+ return { ok: true, used: next, limit, remaining: limit - next }
443
+ }
444
+
445
+ /** 预算保障:超限时自动压缩旧内容腾位;压缩成功返回 compacted:true,否则 ok:false。 */
446
+ async ensureBudget(agent, layer, text) {
447
+ const acct = this.accountWrite(agent, layer, text)
448
+ if (acct.ok) return { ok: true, acct }
449
+ const compacted = await this.compactLayer(agent, layer)
450
+ if (compacted) {
451
+ const acct2 = this.accountWrite(agent, layer, text)
452
+ if (acct2.ok) return { ok: true, acct: acct2, compacted: true }
453
+ }
454
+ return { ok: false, acct }
455
+ }
456
+
457
+ /** 压缩层内"今天之前"的旧段落:AI 提炼要点替换,不可用时归档最早段落;10 分钟节流。 */
458
+ async compactLayer(agent, layer) {
459
+ const now = Date.now()
460
+ if (this._lastCompactAt && now - this._lastCompactAt < 10 * 60 * 1000) return false
461
+ const p = await this.resolvePaths(agent)
462
+ const cur = (layer === 'user' ? this.state.userText : this.state.notesText) || ''
463
+ if (!cur.trim()) return true
464
+ const limit = layer === 'user' ? 4000 : 3000
465
+ const today = this.memToday()
466
+ // 按 "## 标题" 切段,今天段落保留,之前段落为压缩对象;文件头非 ## 内容归入头部段,不丢失
467
+ const segs = []
468
+ let curSeg = { title: '(文件头)', body: [], isToday: false }
469
+ for (const ln of cur.split('\n')) {
470
+ const m = ln.match(/^##\s+(.+)$/)
471
+ if (m) {
472
+ if (curSeg) segs.push(curSeg)
473
+ const title = m[1].trim()
474
+ curSeg = { title, body: [], isToday: /^\d{4}-\d{2}-\d{2}/.test(title) && title.slice(0, 10) === today }
475
+ } else if (curSeg) {
476
+ curSeg.body.push(ln)
477
+ }
478
+ }
479
+ if (curSeg) segs.push(curSeg)
480
+ const oldSegs = segs.filter((s) => !s.isToday)
481
+ const todaySegs = segs.filter((s) => s.isToday)
482
+ const oldText = oldSegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
483
+ const todayText = todaySegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
484
+ const target = Math.max(limit, 200) // 压缩后旧部分目标:不超过层上限
485
+ if (oldText.length <= target) return true // 旧内容本来就不大,无需压缩
486
+ let newOld = ''
487
+ if (this._subagents && agent) {
488
+ try {
489
+ const layerName = layer === 'user' ? '用户级记忆' : '项目笔记'
490
+ const prompt = [
491
+ '你是记忆整理员。下面的' + layerName + '文件里"今天之前"的旧内容需要压缩,因为今天要写入的新内容已超当日预算。',
492
+ '规则:',
493
+ '- 保留所有仍有长期价值的硬信息:强制规则、偏好、关键决策、约定、账号/路径等',
494
+ '- 合并重复、删除已失效/过期条目、把长句压到 20 字内',
495
+ '- 输出 markdown,可用"## 主题"分组,输出将直接替换旧内容',
496
+ '- 只输出压缩结果正文,不要任何解释',
497
+ '',
498
+ '待压缩旧内容:',
499
+ truncateTail(oldText, 6000),
500
+ ].join('\n')
501
+ const out = await this.runSubagent(prompt, 'auto-memory-compact', agent)
502
+ if (out && !out.includes('(无)')) newOld = out.trim()
503
+ } catch (e) {
504
+ console.error('[dsh-auto-memory] compactLayer AI failed', e && e.message ? e.message : e)
505
+ }
506
+ }
507
+ if (!newOld) {
508
+ // AI 不可用降级:把最早段落移入归档文件,直到旧部分 ≤ target(保底不丢)
509
+ const archiveFile = layer === 'user'
510
+ ? path.join(dshHome(), 'memory', 'archived-user.md')
511
+ : path.join(p.projectDir, 'archive', 'notes-archived.md')
512
+ await mkdir(path.dirname(archiveFile), { recursive: true })
513
+ let segs2 = oldSegs.slice()
514
+ let remain = oldText
515
+ while (remain.length > target && segs2.length) {
516
+ const seg = segs2.shift()
517
+ await this.appendText(archiveFile, '\n## ' + seg.title + '\n' + seg.body.join('\n'))
518
+ remain = segs2.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
519
+ }
520
+ newOld = remain.slice(0, target)
521
+ }
522
+ newOld = newOld.slice(0, target)
523
+ const body = [newOld, todayText].filter(Boolean).join('\n\n')
524
+ await this.writeFull(layer === 'user' ? p.userFile : p.notesPath, body)
525
+ if (layer === 'user') this.state.userText = body
526
+ else this.state.notesText = body
527
+ // 压缩腾出的空间让当日额度重新计(今天段落仍在文件里,不丢)
528
+ if (this._budgets) {
529
+ const b = this._budgets.get('today')
530
+ if (b && b.date === this.memToday()) b[layer] = 0
531
+ }
532
+ this._lastCompactAt = now
533
+ this.state.loadedAt = Date.now()
534
+ console.log('[dsh-auto-memory] compacted ' + layer + ' memory: ' + cur.length + ' -> ' + body.length + ' chars')
535
+ return true
536
+ }
537
+
298
538
  // ---------- 注入渲染(同步,基于缓存) ----------
299
539
  renderMemory(context) {
300
540
  const s = this.state
@@ -302,7 +542,11 @@ class MemoryEngine {
302
542
  const budget = Math.max(Number(cfg.injectBudgetChars) || 2400, 400)
303
543
  const lines = []
304
544
  lines.push('<memory_system>')
305
- lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + todayStr())
545
+ lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + this.memToday() + '(日界 ' + (Number(cfg.dayBoundaryMinutes) || 450) + ' 分钟,凌晨归前一天)' + (cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估'))
546
+ // 记忆地图:告诉模型其他工作区记忆存在,需要时用 memory_recall 跨区检索
547
+ if (s.workspaceMap && s.workspaceMap.length) {
548
+ lines.push('其他工作区记忆(开发/排查时可调用 memory_recall 检索其日志/笔记): ' + s.workspaceMap.join('、'))
549
+ }
306
550
  let used = 0
307
551
  const part = (title, text, max) => {
308
552
  if (!text) return
@@ -347,15 +591,20 @@ class MemoryEngine {
347
591
  lines.push('\n[欢迎回来]')
348
592
  lines.push('用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。')
349
593
  }
594
+ lines.push('\n[铭文 · 每轮提醒 ' + nowHms() + ']')
595
+ lines.push('思维链=本轮推理(用完即焚);铭文=落盘的记忆文件(跨会话永久)。你的记忆更新必须落在铭文层——显式调用工具写盘,不能只"想过"。')
596
+ lines.push('本提醒由框架在每一轮对话开始时重新注入(时间戳即刷新证明,衰减期只有一轮):读写只走工具、路径写死、每轮结束框架自动评估沉淀兜底——记忆无法被绕过,也不依赖自觉。')
350
597
  lines.push('\n[记忆写入纪律 — 必须遵守]')
351
- lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(CodeBuddy/Claude Code/Codex 等会话),不要凭空猜测**。')
598
+ lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(AI 助手/CodeBuddy/Claude Code/Codex 会话),不要凭空猜测**。')
352
599
  lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆;检索时在正文中说明"我先查一下之前的记录"。')
353
600
  lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
354
601
  lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
355
602
  lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
603
+ lines.push('- 每日写入预算(所有会话共享一天额度,跨天自动重置):用户级 ≤4000 字/天、项目笔记 ≤3000 字/天;超限不拒绝——框架自动把"今天之前"的旧内容交给 AI 浓缩成要点腾出空间(10 分钟内只压缩一次),AI 不可用时归档最早段落,信息不丢;每日日志无预算。')
356
604
  lines.push('- **记忆操作必须在正文可见(摘要链)**:调用 memory_log/note/user/reflect 更新记忆后,必须把结果写进本轮回复的正文文本(用户直接看到的那段文字,不是工具调用区),并在**回复末尾**用加粗或换行使其醒目(如"**已更新今日日志**\n新增:修复了XXX");调用 memory_recall/memory_external 检索时,在正文开头写明"我查了记忆,发现..."。工具返回值只是辅助,正文转述是强制要求。')
357
605
  lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
358
- lines.push('- 定期调用 memory_maintain 归档 30 天前日志;不存密钥,除非用户明确要求。')
606
+ lines.push('- 定期调用 memory_maintain 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
607
+ lines.push('- 自动沉淀:每轮对话结束,插件会自动评估本轮内容,把有记录价值的写进今日日志([自动沉淀] 标记),有长期价值的升格到项目笔记/用户级记忆,寒暄轮自动跳过。你仍须按上方纪律转述自己的显式记忆操作;也可调用 memory_consolidate 让 AI 读日志发散提炼长期要点。')
359
608
  lines.push('- 记忆仅作补充,不替代正常回复与交付物。')
360
609
  lines.push('</memory_system>')
361
610
  return lines.join('\n')
@@ -387,6 +636,14 @@ class MemoryEngine {
387
636
  greetingData() {
388
637
  const hour = new Date().getHours()
389
638
  const period = hour < 6 ? '凌晨' : hour < 9 ? '早上好' : hour < 12 ? '上午好' : hour < 14 ? '中午好' : hour < 18 ? '下午好' : hour < 22 ? '晚上好' : '夜深了'
639
+ // 时段 key(与 client 一致):凌晨/夜深 归入 morning/evening
640
+ const seg = hour < 6 ? 'morning' : hour < 9 ? 'morning' : hour < 12 ? 'forenoon' : hour < 14 ? 'noon' : hour < 18 ? 'afternoon' : 'evening'
641
+ // 问候:greetings/{date}.json 按时段存;旧 .md 纯文本兼容
642
+ let greetText = ''
643
+ const raw = this.state.todayGreeting || ''
644
+ if (raw) {
645
+ try { const j = JSON.parse(raw); greetText = (j && (j[seg] || '')) || '' } catch (e) { greetText = raw }
646
+ }
390
647
  // 昨天 = 最近一条日志(今天之前的);今天有条目也算最近
391
648
  const recent = this.state.recentLogs[0] || null
392
649
  const entries = recent ? recent.text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- ')).map((l) => {
@@ -395,9 +652,9 @@ class MemoryEngine {
395
652
  }) : []
396
653
  return {
397
654
  period,
398
- date: todayStr(),
399
- hasGreeting: !!this.state.todayGreeting,
400
- greeting: this.state.todayGreeting,
655
+ date: this.memToday(),
656
+ hasGreeting: !!greetText,
657
+ greeting: greetText,
401
658
  yesterdayDate: recent ? recent.date : '',
402
659
  entries,
403
660
  hasPendingReflection: !!this.state.pendingReflection,
@@ -426,7 +683,7 @@ class MemoryEngine {
426
683
  const p = await this.resolvePaths(agent)
427
684
  const out = []
428
685
  const hits = []
429
- const scanFile = async (label, filePath, maxMatches = 3) => {
686
+ const scanFile = async (label, filePath, maxMatches = 3, target = hits) => {
430
687
  const text = await this.readTextSafe(filePath)
431
688
  if (!text) return
432
689
  const matched = []
@@ -436,7 +693,7 @@ class MemoryEngine {
436
693
  if (matched.length >= maxMatches) break
437
694
  }
438
695
  }
439
- if (matched.length) hits.push({ where: label, matches: matched })
696
+ if (matched.length) target.push({ where: label, matches: matched })
440
697
  }
441
698
  // 读取顺序:progress(日志/反思)先行,再读 memory(用户级/项目笔记)
442
699
  const logs = await this.listDailyLogs(p.projectDir, 40)
@@ -455,11 +712,33 @@ class MemoryEngine {
455
712
  out.push('== 本地记忆文件命中 ==')
456
713
  for (const h of hits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
457
714
  }
715
+ // 其他 DSH 工作区记忆(跨工作区检索:从 sessions 发现的所有工作区,任何模型/新会话都能查到)
716
+ const otherHits = []
717
+ try {
718
+ const cwds = await this.discoverWorkspaces()
719
+ for (const cwd of cwds) {
720
+ if (hits.length + otherHits.length >= limit) break
721
+ if (cwd === p.ws) continue
722
+ const p2 = this.projectDirOf(cwd)
723
+ if (p2 === p.projectDir) continue
724
+ const wsName = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
725
+ const logs2 = await this.listDailyLogs(p2, 15)
726
+ for (const log of logs2) {
727
+ if (hits.length + otherHits.length >= limit) break
728
+ await scanFile('其他工作区[' + wsName + '] ' + log.name, path.join(p2, log.name), 2, otherHits)
729
+ }
730
+ await scanFile('其他工作区[' + wsName + '] 项目笔记', path.join(p2, 'MEMORY.md'), 2, otherHits)
731
+ }
732
+ } catch (e) {}
733
+ if (otherHits.length) {
734
+ out.push('== 其他工作区记忆命中(跨工作区) ==')
735
+ for (const h of otherHits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
736
+ }
458
737
  // 外部记忆(其他 AI 工具遗产)检索
459
738
  try {
460
739
  const extHits = await this.external.search(query, Math.max(limit - hits.length, 2))
461
740
  if (extHits.length) {
462
- out.push('== 外部记忆命中(CodeBuddy/Claude/Codex/项目约定等) ==')
741
+ out.push('== 外部记忆命中(AI 助手/CodeBuddy/Claude/Codex/项目约定) ==')
463
742
  for (const h of extHits) out.push('· ' + h.source + '(' + h.tool + '):\n' + h.lines.map((m) => ' - ' + m).join('\n'))
464
743
  }
465
744
  } catch (e) {}
@@ -484,6 +763,299 @@ class MemoryEngine {
484
763
  return '[记忆检索] "' + q + '":\n' + out.join('\n')
485
764
  }
486
765
 
766
+ /** 多关键词扫描三层记忆(供智能检索用),返回 {where, line} 列表。 */
767
+ async collectHits(keywords, limit) {
768
+ const p = await this.resolvePaths(undefined)
769
+ const hits = []
770
+ const seen = new Set()
771
+ const scanFile = async (label, filePath) => {
772
+ const text = await this.readTextSafe(filePath)
773
+ if (!text) return
774
+ let matched = 0
775
+ for (const line of text.split('\n')) {
776
+ const lt = line.toLowerCase()
777
+ if (keywords.some((k) => k && lt.includes(k))) {
778
+ const key = label + '|' + line.trim().slice(0, 80)
779
+ if (seen.has(key)) continue
780
+ seen.add(key)
781
+ hits.push({ where: label, line: line.trim().slice(0, 220) })
782
+ matched++
783
+ if (matched >= 2) break
784
+ }
785
+ }
786
+ }
787
+ const logs = await this.listDailyLogs(p.projectDir, 30)
788
+ for (const log of logs) { if (hits.length >= limit) break; await scanFile(log.name, path.join(p.projectDir, log.name)) }
789
+ const reflections = await this.listReflections(p.reflectDir, 20)
790
+ for (const rf of reflections) { if (hits.length >= limit) break; await scanFile('reflections/' + rf.name, path.join(p.reflectDir, rf.name)) }
791
+ await scanFile('~' + p.userFile.slice(homedir().length), p.userFile)
792
+ await scanFile(p.projectDir + '/MEMORY.md', p.notesPath)
793
+ return hits.slice(0, limit)
794
+ }
795
+
796
+ /** 智能检索:AI 发散关键词 → 扫描三层记忆 → AI 综合答案。 */
797
+ async smartRecall(query, agent) {
798
+ const q = String(query || '').trim()
799
+ if (!q) return { answer: '检索内容为空。', keywords: [], hits: [] }
800
+ // 第一轮:AI 把自然语言转成关键词
801
+ const kwPrompt = [
802
+ '你是记忆检索助手。用户想从记忆里查找信息,请把用户的自然语言描述转换成 3-6 个检索关键词(短词/短语,每行一个):',
803
+ '- 覆盖核心词、同义词、相关词(如"上次发布踩的坑" → 发布 / 踩坑 / 发布失败 / npm)',
804
+ '- 只输出关键词,每行一个,不要序号、不要解释、不要多余文字',
805
+ '',
806
+ '用户查询: ' + q,
807
+ ].join('\n')
808
+ const kwText = await this.withTimeout(this.runSubagent(kwPrompt, 'auto-memory-smart-kw', agent), 8000, '')
809
+ const keywords = (kwText || '').split('\n').map((l) => l.trim().replace(/^[-•\d.\s]+/, '')).filter((l) => l && l.length <= 24).slice(0, 6)
810
+ if (!keywords.length) keywords.push(q.slice(0, 20))
811
+ // 扫描三层记忆
812
+ const hits = await this.collectHits(keywords.map((k) => k.toLowerCase()), 14)
813
+ let answer = ''
814
+ if (!hits.length) {
815
+ answer = '没找到与"' + q + '"直接相关的记忆记录。可以换个说法,或试试普通关键词检索。'
816
+ } else {
817
+ const hitText = hits.map((h, i) => (i + 1) + '. [' + h.where + '] ' + h.line).join('\n')
818
+ const ansPrompt = [
819
+ '你是用户的记忆管家。根据下面检索命中的记忆片段,回答用户的查询。要求:',
820
+ '- 用自然语言回答(60-200字),像朋友交谈,不要罗列堆砌',
821
+ '- 引用命中里的关键信息(时间/项目/结论),并注明来自哪份记忆(日志日期/项目笔记/用户级记忆)',
822
+ '- 命中内容不足以回答时,如实说明,并概括命中了什么相关片段',
823
+ '- 不要编造记忆里没有的信息',
824
+ '',
825
+ '用户查询: ' + q,
826
+ '',
827
+ '检索关键词: ' + keywords.join(' / '),
828
+ '',
829
+ '命中片段:',
830
+ hitText,
831
+ ].join('\n')
832
+ answer = await this.withTimeout(this.runSubagent(ansPrompt, 'auto-memory-smart-ans', agent), 15000, '')
833
+ if (!answer) answer = '已检索到 ' + hits.length + ' 条相关记录,但 AI 综合失败(可能对话繁忙),请看下方命中明细。'
834
+ }
835
+ return { answer, keywords, hits: hits.map((h) => ({ where: h.where, line: h.line })) }
836
+ }
837
+
838
+ // ---------- 工作区总览(跨工作区全局总结) ----------
839
+ /** 读 jsonl 首行(session header)。 */
840
+ readFirstLine(p) {
841
+ return new Promise((resolve) => {
842
+ const rs = createReadStream(p, { encoding: 'utf8' })
843
+ let buf = ''
844
+ rs.on('data', (chunk) => {
845
+ buf += chunk
846
+ const i = buf.indexOf('\n')
847
+ if (i >= 0) { rs.destroy(); resolve(buf.slice(0, i)) }
848
+ })
849
+ rs.on('end', () => resolve(buf))
850
+ rs.on('error', () => resolve(''))
851
+ })
852
+ }
853
+
854
+ /** 扫描 ~/.dsh/sessions 下所有会话,提取去重后的工作区路径。 */
855
+ async discoverWorkspaces() {
856
+ const out = new Map()
857
+ const sessionsDir = path.join(dshHome(), 'sessions')
858
+ const walk = async (dir, depth) => {
859
+ if (depth > 4) return
860
+ let entries
861
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch (e) { return }
862
+ for (const en of entries) {
863
+ if (out.size >= 30) return
864
+ if (en.isDirectory()) await walk(path.join(dir, en.name), depth + 1)
865
+ else if (en.isFile() && en.name.endsWith('.jsonl')) {
866
+ try {
867
+ const first = await this.readFirstLine(path.join(dir, en.name))
868
+ if (first) {
869
+ const h = JSON.parse(first)
870
+ if (h && typeof h.cwd === 'string' && h.cwd) out.set(h.cwd, true)
871
+ }
872
+ } catch (e) {}
873
+ }
874
+ }
875
+ }
876
+ await walk(sessionsDir, 0)
877
+ return Array.from(out.keys())
878
+ }
879
+
880
+ /** 读取某工作区的近期记忆(最近5天日志 + 项目笔记头部),无 .dsh-memory 返回 null。 */
881
+ async readWorkspaceMemory(cwd) {
882
+ const dir = this.projectDirOf(cwd)
883
+ try { const st = await stat(dir); if (!st.isDirectory()) return null } catch (e) { return null }
884
+ const logs = []
885
+ try {
886
+ const entries = await readdir(dir, { withFileTypes: true })
887
+ 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)
888
+ for (const d of dates) {
889
+ const t = await this.readTextSafe(path.join(dir, d + '.md'))
890
+ if (t) logs.push({ date: d, text: truncateTail(t, 1200) })
891
+ }
892
+ } catch (e) {}
893
+ const notes = await this.readTextSafe(path.join(dir, 'MEMORY.md'))
894
+ return { logs, notes: truncateHead(notes || '', 800) }
895
+ }
896
+
897
+ /** 工作区总览:每个工作区一个小总结 + 区内细分总结;结果缓存到用户级 ~/.dsh/memory/workspaces-summary.json(跨工作区全局)。 */
898
+ async workspaceOverview(agent, force) {
899
+ const cacheFile = path.join(dshHome(), 'memory', 'workspaces-summary.json')
900
+ if (!force) {
901
+ try {
902
+ const raw = await this.readTextSafe(cacheFile)
903
+ if (raw) {
904
+ const j = JSON.parse(raw)
905
+ if (j && Array.isArray(j.workspaces)) return { workspaces: j.workspaces, cached: true, generatedAt: j.generatedAt }
906
+ }
907
+ } catch (e) {}
908
+ }
909
+ const cwds = await this.discoverWorkspaces()
910
+ const workspaces = []
911
+ for (const cwd of cwds.slice(0, 8)) {
912
+ const mem = await this.readWorkspaceMemory(cwd)
913
+ if (!mem || (!mem.logs.length && !mem.notes)) continue
914
+ const name = String(cwd).split(/[\\/]/).filter(Boolean).pop() || cwd
915
+ let summary = ''
916
+ let items = []
917
+ if (mem.logs.length || mem.notes) {
918
+ const logText = mem.logs.map((l) => '[' + l.date + '] ' + l.text).join('\n')
919
+ const prompt = [
920
+ '你是用户的记忆管家。下面是某个工作区的近期记忆(最近5天日志+项目笔记),请:',
921
+ '1. 写一段 40-100 字的小总结:这个工作区在做什么、最近进展与主要成果,口语化一点。',
922
+ '2. 列出 2-5 条细分总结(每条 15-40 字),按日期或主题归纳(如"8-14: 发布 v0.1.9"、"进行中: 扩展框架")。',
923
+ '输出格式(严格遵守,不要多余文字):',
924
+ '[SUMMARY]',
925
+ '<小总结>',
926
+ '[ITEM]',
927
+ '<细分1>',
928
+ '[ITEM]',
929
+ '<细分2>',
930
+ '',
931
+ '工作区: ' + name,
932
+ '',
933
+ '近期日志:',
934
+ logText.slice(0, 6000) || '(无)',
935
+ '',
936
+ '项目笔记:',
937
+ mem.notes || '(无)',
938
+ ].join('\n')
939
+ const text = await this.withTimeout(this.runSubagent(prompt, 'auto-memory-ws-summary', agent), 30000, '')
940
+ let cur = null
941
+ for (const raw of String(text || '').split('\n')) {
942
+ const l = raw.trim()
943
+ if (!l) continue
944
+ if (l.startsWith('[SUMMARY]')) { cur = 'summary'; continue }
945
+ if (l.startsWith('[ITEM]')) { cur = 'item'; continue }
946
+ if (cur === 'summary' && l) summary += (summary ? '\n' : '') + l
947
+ else if (cur === 'item' && l && items.length < 6) items.push(l.slice(0, 120))
948
+ }
949
+ }
950
+ workspaces.push({
951
+ path: cwd,
952
+ name,
953
+ summary,
954
+ items,
955
+ logCount: mem.logs.reduce((a, l) => a + (l.text.split('\n').filter((x) => x.trim().startsWith('- ')).length), 0),
956
+ dateRange: mem.logs.length ? (mem.logs[mem.logs.length - 1].date + ' ~ ' + mem.logs[0].date) : '',
957
+ })
958
+ }
959
+ const result = { workspaces, generatedAt: Date.now() }
960
+ try {
961
+ await mkdir(path.dirname(cacheFile), { recursive: true })
962
+ await writeFile(cacheFile, JSON.stringify(result, null, 2), 'utf8')
963
+ } catch (e) {}
964
+ return { workspaces, cached: false, generatedAt: result.generatedAt }
965
+ }
966
+
967
+ // ---------- 调试中心(为提 issue 提供诊断信息) ----------
968
+ async debugInfo() {
969
+ const p = await this.resolvePaths(undefined)
970
+ const startTime = Date.now() - process.uptime() * 1000
971
+ // host 版本与文件时间(判断"代码改了但没重启")
972
+ let version = ''
973
+ let indexPath = ''
974
+ let indexMtime = 0
975
+ try {
976
+ indexPath = fileURLToPath(import.meta.url)
977
+ const pkgPath = path.join(path.dirname(indexPath), '..', 'package.json')
978
+ const raw = await this.readTextSafe(pkgPath)
979
+ if (raw) { try { version = JSON.parse(raw).version || '' } catch (e) {} }
980
+ try { indexMtime = (await stat(indexPath)).mtimeMs } catch (e) {}
981
+ } catch (e) {}
982
+ // 轮询心跳
983
+ const hbFile = path.join(dshHome(), 'memory', 'polling-heartbeat.json')
984
+ let heartbeat = { exists: false }
985
+ try {
986
+ const raw = await this.readTextSafe(hbFile)
987
+ if (raw) { heartbeat = Object.assign({ exists: true }, JSON.parse(raw)) }
988
+ } catch (e) {}
989
+ // subagents
990
+ let providers = []
991
+ try { providers = this._subagents && this._subagents.list ? this._subagents.list() : [] } catch (e) {}
992
+ // 记忆文件状态
993
+ const sizeMtime = async (f) => {
994
+ try { const s = await stat(f); return { exists: true, size: s.size, mtime: s.mtimeMs } } catch (e) { return { exists: false } }
995
+ }
996
+ // 项目笔记重复日期标题检测
997
+ let duplicateHeadings = 0
998
+ try {
999
+ const notes = await this.readTextSafe(p.notesPath)
1000
+ const seen = {}
1001
+ for (const line of String(notes || '').split('\n')) {
1002
+ const m = line.match(/^##\s+(\d{4}-\d{2}-\d{2})\s*$/)
1003
+ if (m) { seen[m[1]] = (seen[m[1]] || 0) + 1 }
1004
+ }
1005
+ duplicateHeadings = Object.values(seen).filter((n) => n > 1).length
1006
+ } catch (e) {}
1007
+ return {
1008
+ host: {
1009
+ pid: process.pid,
1010
+ startTime,
1011
+ uptimeSec: Math.round(process.uptime()),
1012
+ version,
1013
+ indexPath,
1014
+ indexMtime,
1015
+ needsRestart: !!(indexMtime && indexMtime > startTime + 5000),
1016
+ },
1017
+ config: this.config,
1018
+ heartbeat,
1019
+ autoConsolidate: {
1020
+ enabled: this.config.autoConsolidate !== false,
1021
+ minChars: Math.max(Number(this.config.autoConsolidateMinChars) || 60, 20),
1022
+ consolidating: !!this._consolidating,
1023
+ pendingQueue: (this._pendingConsolidations || []).length,
1024
+ stats: this.autoStats,
1025
+ },
1026
+ subagents: { available: !!this._subagents, providers },
1027
+ memoryFiles: {
1028
+ user: await sizeMtime(p.userFile),
1029
+ notes: await sizeMtime(p.notesPath),
1030
+ log: await sizeMtime(p.logPath),
1031
+ reflectionsDir: await sizeMtime(p.reflectDir),
1032
+ calendar: await sizeMtime(p.calendarPath),
1033
+ workspacesCache: await sizeMtime(path.join(dshHome(), 'memory', 'workspaces-summary.json')),
1034
+ summariesDir: await sizeMtime(path.join(p.projectDir, 'summaries')),
1035
+ },
1036
+ duplicateHeadings,
1037
+ budgets: (() => {
1038
+ const out = []
1039
+ if (this._budgets) {
1040
+ for (const [, b] of this._budgets.entries()) {
1041
+ out.push({
1042
+ scope: 'daily', date: b.date,
1043
+ userUsed: b.user, userLimit: 4000,
1044
+ noteUsed: b.note, noteLimit: 3000,
1045
+ userFileSize: (this.state.userText || '').length,
1046
+ noteFileSize: (this.state.notesText || '').length,
1047
+ lastCompactAt: this._lastCompactAt || 0,
1048
+ })
1049
+ }
1050
+ }
1051
+ return out
1052
+ })(),
1053
+ today: todayStr(),
1054
+ memoryDate: this.memToday(),
1055
+ now: Date.now(),
1056
+ }
1057
+ }
1058
+
487
1059
  // ---------- 反思 ----------
488
1060
  async saveReflection(date, text, agent) {
489
1061
  if (!DATE_RE.test(date)) return 'memory_reflect: date 必须是 YYYY-MM-DD。'
@@ -594,35 +1166,372 @@ class MemoryEngine {
594
1166
  }
595
1167
 
596
1168
  /** 生活化总结:调用 DSH 的 AI(subagent)发散地总结某时段的工作(完成/收尾/烦恼/暂停的事)。 */
597
- async summarizePeriod(period, agent) {
1169
+ async summarizePeriod(period, agent, force) {
598
1170
  const ps = this.periodSummary()
599
1171
  const groups = ps.groups || {}
600
1172
  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
1173
+ if (!items.length) return { summary: '', works: [], cached: true, generatedAt: Date.now() }
1174
+ // 缓存文件(.dsh-memory/summaries/),force=false 且缓存存在时直接返回,不重复生成
1175
+ let cacheFile = ''
1176
+ try {
1177
+ const p = await this.resolvePaths(agent)
1178
+ cacheFile = path.join(p.projectDir, 'summaries', this.memToday() + '-' + period + '.json')
1179
+ } catch (e) {}
1180
+ if (!force && cacheFile) {
1181
+ try {
1182
+ const raw = await this.readTextSafe(cacheFile)
1183
+ if (raw) {
1184
+ const j = JSON.parse(raw)
1185
+ if (j && j.summary && Array.isArray(j.works)) return { summary: j.summary, works: j.works, generatedAt: j.generatedAt || Date.now(), cached: true }
1186
+ }
1187
+ } catch (e) {}
1188
+ }
605
1189
  const prompt = [
606
- '你是一个温暖、细腻的生活助理。请根据下面这位用户的时段工作记录,写一段自然、口语化的总结(80-160字),像朋友聊天一样:',
607
- '- 概括这个时段完成了什么(挑重要的说,不用逐条罗列)',
608
- '- 提到是否收尾/完成(如果有明显没做完的迹象就轻轻点一下,不要断言)',
609
- '- 如果看得出可能的烦恼或卡住的事,温和地提一句;看不出就跳过',
610
- '- 语气轻松自然,不要列表、不要小标题、不要"总结:"前缀',
1190
+ '你是一个温暖、细腻的生活助理。请阅读用户某时段的原始工作记录,做两件事:',
1191
+ '1. 写一段生活化总结(80-160字),像朋友聊天:概括完成的事、是否收尾、可能的烦恼(温和带过,看不出就跳过);不要列表、不要小标题、不要"总结:"前缀。',
1192
+ '2. 把原始记录归纳成若干项"工作"(3-6项),每项给简短标题(8-16字),并列出该工作的细点(每项2-4条,每条20-40字,从原始记录提炼精简,不要逐字复制长句)。',
1193
+ '输出格式(严格遵守,不要多余文字):',
1194
+ '[SUMMARY]',
1195
+ '<生活化总结>',
1196
+ '[WORK] <工作标题1>',
1197
+ '- <细点1>',
1198
+ '- <细点2>',
1199
+ '[WORK] <工作标题2>',
1200
+ '- <细点1>',
611
1201
  '',
612
1202
  '用户时段工作记录(' + period + '):',
613
1203
  items.map((x) => '- ' + x).join('\n'),
614
1204
  ].join('\n')
1205
+ const text = await this.runSubagent(prompt, 'auto-memory-summarize', agent)
1206
+ if (!text) return { summary: '', works: [], cached: false, generatedAt: Date.now() }
1207
+ // 解析 [SUMMARY]/[WORK]/- 结构
1208
+ let summary = ''
1209
+ const works = []
1210
+ let cur = null
1211
+ for (const raw of text.split('\n')) {
1212
+ const l = raw.trim()
1213
+ if (!l) continue
1214
+ if (l.startsWith('[SUMMARY]')) continue
1215
+ if (l.startsWith('[WORK]')) { cur = { title: l.slice(6).trim(), points: [] }; works.push(cur); continue }
1216
+ if (l.startsWith('- ') || l.startsWith('• ')) {
1217
+ const pt = l.replace(/^[-•]\s*/, '').trim()
1218
+ if (cur && pt) cur.points.push(pt)
1219
+ else if (pt) summary += (summary ? '\n' : '') + pt
1220
+ continue
1221
+ }
1222
+ if (!cur && l) summary += (summary ? '\n' : '') + l
1223
+ }
1224
+ const result = { summary, works, generatedAt: Date.now(), cached: false }
1225
+ if (cacheFile) {
1226
+ try { await mkdir(path.dirname(cacheFile), { recursive: true }); await writeFile(cacheFile, JSON.stringify(result, null, 2), 'utf8') } catch (e) {}
1227
+ }
1228
+ return result
1229
+ }
1230
+
1231
+ /** 今日 AI 问候(按时段,每天每时段生成一次并缓存到 greetings/{date}.json)。 */
1232
+ async greetToday(agent) {
1233
+ const hour = new Date().getHours()
1234
+ const seg = hour < 6 ? 'morning' : hour < 9 ? 'morning' : hour < 12 ? 'forenoon' : hour < 14 ? 'noon' : hour < 18 ? 'afternoon' : 'evening'
1235
+ const segLabel = { morning: '早上', forenoon: '上午', noon: '中午', afternoon: '下午', evening: '晚上' }[seg]
1236
+ const p = await this.resolvePaths(agent)
1237
+ const greetFile = path.join(p.greetDir, this.memToday() + '.json')
1238
+ // 已有该时段缓存 → 直接返回
615
1239
  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()
1240
+ const raw = await this.readTextSafe(greetFile)
1241
+ if (raw) {
1242
+ const j = JSON.parse(raw)
1243
+ if (j && j[seg]) return { greeting: j[seg], cached: true }
1244
+ }
1245
+ } catch (e) {}
1246
+ // 今日记录(供问候引用最近几条)
1247
+ 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)
1248
+ const prompt = [
1249
+ '你是一个温暖的生活助理。现在是' + segLabel + ',请给用户写一句简短的拟人化问候(30-60字),像朋友一样:',
1250
+ '- 按时间段自然问候(' + segLabel + '好/辛苦了之类),自然提起今天完成的主要工作(挑1-2件最重要的),可以带一句贴心提醒(如早点休息)',
1251
+ '- 语气轻松温暖,不要列表、不要"总结:"、不要感叹号堆砌、不要 emoji',
1252
+ '- 只输出问候语本身,不要任何前后缀',
1253
+ '',
1254
+ '今天已记录的工作(选重要的提):',
1255
+ (items.length ? items.map((x) => '- ' + x).join('\n') : '(今天还没有记录)'),
1256
+ ].join('\n')
1257
+ const text = await this.runSubagent(prompt, 'auto-memory-greet', agent)
1258
+ if (!text) return { greeting: '', cached: false }
1259
+ // 合并写缓存(同一天多时段共存)
1260
+ let all = {}
1261
+ try { const raw = await this.readTextSafe(greetFile); if (raw) { const j = JSON.parse(raw); if (j) all = j } } catch (e) {}
1262
+ all[seg] = text
1263
+ try { await mkdir(p.greetDir, { recursive: true }); await writeFile(greetFile, JSON.stringify(all, null, 2), 'utf8') } catch (e) {}
1264
+ return { greeting: text, cached: false }
1265
+ }
1266
+
1267
+ /** 调用 DSH subagent 发散/提炼一段文本(90s 超时,结果取 text 块;parent 用最近 agent)。 */
1268
+ async runSubagent(text, label, agent) {
1269
+ const subagents = this._subagents
1270
+ if (!subagents) return ''
1271
+ // parent 必须是完整 agent 对象(captureDelegatedPolicyOverrides 读 parent.ctx/parent.session);路由调用用缓存的最近 agent
1272
+ const parent = agent || this._lastAgent
1273
+ // 动态选择可用的 subagent provider(优先 spawn,否则取已注册的第一个)
1274
+ let providerName = 'spawn'
1275
+ let registered = []
1276
+ try {
1277
+ registered = subagents.list ? subagents.list() : []
1278
+ if (Array.isArray(registered) && registered.length && !registered.includes('spawn')) providerName = registered[0]
1279
+ } catch (e2) {}
1280
+ const controller = new AbortController()
1281
+ const timer = setTimeout(() => controller.abort(label + ' timeout'), 90000)
1282
+ try {
1283
+ // prompt 必须是 block 数组(createUserMessage 校验 content.some)
1284
+ const run = await subagents.start(providerName, {
1285
+ label,
1286
+ prompt: [{ type: 'text', text }],
1287
+ signal: controller.signal,
1288
+ ...(parent ? { parent } : {}),
1289
+ })
1290
+ const result = await run.result
1291
+ const blocks = result && result.output ? result.output : []
1292
+ return blocks.filter((b) => b && b.type === 'text').map((b) => b.text).join('').trim()
620
1293
  } catch (e) {
621
- console.error('[dsh-auto-memory] summarize failed', e && e.message ? e.message : e)
1294
+ console.error('[dsh-auto-memory] ' + label + ' failed', e && e.message ? e.message : e)
622
1295
  return ''
1296
+ } finally {
1297
+ clearTimeout(timer)
623
1298
  }
624
1299
  }
625
1300
 
1301
+ /** 带超时的 promise(超时返回 fallback,不无限等待)。 */
1302
+ async withTimeout(promise, ms, fallback) {
1303
+ let timer
1304
+ try {
1305
+ return await Promise.race([
1306
+ promise,
1307
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout after ' + ms + 'ms')), ms) }),
1308
+ ])
1309
+ } catch (e) { return fallback }
1310
+ finally { clearTimeout(timer) }
1311
+ }
1312
+
1313
+ /** 每轮对话结束自动沉淀:取本轮 user+assistant 消息 → subagent 判断/提炼 → 写今日日志+升格。 */
1314
+ async consolidateTurn(turn, agent) {
1315
+ if (this._consolidating) return
1316
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
1317
+ if (this.config.autoConsolidate === false) return
1318
+ if (!agent || !agent.session) return
1319
+ // 只处理顶层会话(子代理/接续会话的 header.parentSession 非空,避免子代理轮次误沉淀)
1320
+ try { if (agent.session.header && agent.session.header.parentSession) return } catch (e) {}
1321
+ const minChars = Math.max(Number(this.config.autoConsolidateMinChars) || 60, 20)
1322
+ // 按 turn 去重:同一 agent 的同一轮只处理一次
1323
+ const agentId = agent.id || (agent.session && agent.session.id) || '?'
1324
+ if (!this._lastTurnByAgent) this._lastTurnByAgent = new Map()
1325
+ const lastTurn = this._lastTurnByAgent.get(agentId)
1326
+ if (lastTurn === turn) return
1327
+ this._lastTurnByAgent.set(agentId, turn)
1328
+ // 取本轮最后一条 user + 最后一条 assistant(模型可见消息序列)
1329
+ const messages = extractSessionMessages(agent)
1330
+ if (messages.length < 2) return
1331
+ let userText = ''
1332
+ let assistantText = ''
1333
+ for (const m of messages) {
1334
+ if (m.role === 'user') userText = m.text
1335
+ else if (m.role === 'assistant' && m.text) assistantText = m.text
1336
+ }
1337
+ if (!userText.trim() || !assistantText.trim()) return
1338
+ const combined = userText + '\n' + assistantText
1339
+ if (combined.length < minChars) return
1340
+ this._consolidating = (async () => {
1341
+ try {
1342
+ await this.refresh(agent)
1343
+ const p = await this.resolvePaths(agent)
1344
+ // 今日日志尾部(去重参考)
1345
+ const logTail = truncateTail(this.state.logText || '', 900)
1346
+ const prompt = [
1347
+ '你是用户的记忆管家。刚结束一轮对话,请判断其中是否有值得写入记忆的内容,并提炼要点。',
1348
+ '规则:',
1349
+ '- 寒暄、闲聊、单纯问候、纯测试、无实质内容 → 只输出 (无)',
1350
+ '- 有实质内容(完成工作、修复问题、做出决策、约定规则、用户偏好、讨论结论)时,提炼 1-3 条要点',
1351
+ '- 每条一句话,具体明确,不要泛泛而谈,不要复述对话过程',
1352
+ '- 项目专属的进度 → [LOG];有跨会话长期价值的项目决策/架构/约定 → [NOTE];跨项目通用的用户硬性规则/偏好 → [USER]',
1353
+ '- [NOTE]/[USER] 只在真正长期有价值时用,宁缺毋滥',
1354
+ '输出格式(严格遵守):',
1355
+ '[TOPIC]',
1356
+ '<本轮工作主题标题,8-20字,如"修复抽屉bug与字号功能">',
1357
+ '[LOG]',
1358
+ '- 要点1',
1359
+ '[NOTE]',
1360
+ '- 要点2',
1361
+ '[USER]',
1362
+ '- 规则',
1363
+ '没有值得记录的内容时只输出一行:(无)',
1364
+ '',
1365
+ '本轮用户消息:',
1366
+ userText.slice(0, 3000),
1367
+ '',
1368
+ '本轮助手回复:',
1369
+ assistantText.slice(0, 3000),
1370
+ '',
1371
+ '今日日志已有内容(避免重复记录):',
1372
+ logTail || '(空)',
1373
+ ].join('\n')
1374
+ const text = await this.runSubagent(prompt, 'auto-memory-consolidate', agent)
1375
+ if (!text) {
1376
+ // subagent 失败(返回空):入重试队列,由后台轮询兜底重试
1377
+ if (!this._pendingConsolidations) this._pendingConsolidations = []
1378
+ if (this._pendingConsolidations.length < 5) this._pendingConsolidations.push({ turn, agent })
1379
+ return
1380
+ }
1381
+ if (text.includes('(无)')) return
1382
+ const logPts = []
1383
+ const notePts = []
1384
+ const userPts = []
1385
+ let topicTitle = ''
1386
+ let section = ''
1387
+ for (const raw of text.split('\n')) {
1388
+ const l = raw.trim()
1389
+ if (!l) continue
1390
+ if (l === '[LOG]') { section = 'log'; continue }
1391
+ if (l === '[NOTE]') { section = 'note'; continue }
1392
+ if (l === '[USER]') { section = 'user'; continue }
1393
+ if (l === '[TOPIC]') { section = 'topic'; continue }
1394
+ if (section === 'topic') { if (!topicTitle) topicTitle = l.slice(0, 30); continue }
1395
+ if (l.startsWith('- ') && (section === 'log' || section === 'note' || section === 'user')) {
1396
+ const pt = l.slice(2).trim()
1397
+ if (pt) {
1398
+ if (section === 'log') logPts.push(pt)
1399
+ else if (section === 'note') notePts.push(pt)
1400
+ else userPts.push(pt)
1401
+ }
1402
+ }
1403
+ }
1404
+ const today = this.memToday()
1405
+ let written = 0
1406
+ if (logPts.length) {
1407
+ // 集中式主题分组: ## 主题(HH:MM) + 要点列表(自动沉淀不再混入时间戳流水)
1408
+ const topic = (topicTitle || '自动沉淀') + '(' + nowHm() + ')'
1409
+ const body = await this.appendText(p.logPath, '\n## ' + topic + '\n' + logPts.map((x) => '- ' + x).join('\n'))
1410
+ this.state.logText = body; this.state.logPath = p.logPath
1411
+ written += logPts.length
1412
+ }
1413
+ if (notePts.length) {
1414
+ const r = await this.ensureBudget(agent, 'note', notePts.map((x) => '- ' + x).join('\n'))
1415
+ if (r.ok) {
1416
+ const body = await this.appendText(p.notesPath, '\n## ' + today + '\n' + notePts.map((x) => '- ' + x).join('\n'))
1417
+ this.state.notesText = body
1418
+ written += notePts.length
1419
+ } else {
1420
+ console.log('[dsh-auto-memory] auto-consolidate: NOTE 超预算且压缩不可用,已跳过')
1421
+ }
1422
+ }
1423
+ if (userPts.length) {
1424
+ const r = await this.ensureBudget(agent, 'user', userPts.map((x) => '- ' + x).join('\n'))
1425
+ if (r.ok) {
1426
+ const body = await this.appendText(p.userFile, '\n## ' + today + '\n' + userPts.map((x) => '- ' + x).join('\n'))
1427
+ this.state.userText = body
1428
+ written += userPts.length
1429
+ } else {
1430
+ console.log('[dsh-auto-memory] auto-consolidate: USER 超预算且压缩不可用,已跳过')
1431
+ }
1432
+ }
1433
+ if (written) {
1434
+ // 自动沉淀统计(跨天重置)
1435
+ if (this.autoStats.lastDate !== today) { this.autoStats.count = 0; this.autoStats.lastDate = today }
1436
+ this.autoStats.count += written
1437
+ this.autoStats.lastAt = Date.now()
1438
+ this.autoStats.lastText = (logPts[0] || notePts[0] || userPts[0] || '').slice(0, 80)
1439
+ this.state.loadedAt = Date.now()
1440
+ console.log('[dsh-auto-memory] auto-consolidated turn ' + turn + ': +' + written + ' points (log ' + logPts.length + ', note ' + notePts.length + ', user ' + userPts.length + ')')
1441
+ }
1442
+ } catch (e) {
1443
+ console.error('[dsh-auto-memory] consolidateTurn failed', e && e.message ? e.message : e)
1444
+ } finally {
1445
+ this._consolidating = undefined
1446
+ }
1447
+ })()
1448
+ await this._consolidating
1449
+ }
1450
+
1451
+ /** AI 主动固化(做梦式):读最近日志 → 发散提炼 → 项目笔记/用户级 MEMORY.md 带日期标题。 */
1452
+ async consolidateMemory(agent, days = 7) {
1453
+ const subagents = this._subagents
1454
+ if (!subagents) return 'memory_consolidate: subagents 服务不可用,无法调用 AI 提炼。'
1455
+ const p = await this.resolvePaths(agent)
1456
+ const logs = await this.listDailyLogs(p.projectDir, 60)
1457
+ const recent = []
1458
+ for (const log of logs) {
1459
+ if (recent.length >= days) break
1460
+ const text = await this.readTextSafe(path.join(p.projectDir, log.name))
1461
+ if (text && text.trim()) recent.push({ date: log.date, text: truncateTail(text, 2500) })
1462
+ }
1463
+ if (!recent.length) return 'memory_consolidate: 最近 ' + days + ' 天没有日志,没有可提炼的内容。'
1464
+ const prompt = [
1465
+ '你是用户的长期记忆管家。请阅读下面的工作日志,发散提炼出值得长期记住的内容,把记忆固化成条目。',
1466
+ '规则:',
1467
+ '- 只提炼有跨会话长期价值的:技术决策、架构约定、关键路径、用户偏好/习惯、踩过的坑及其规则',
1468
+ '- 不记临时信息(某次具体修 bug 的过程省略,但其背后的规则/约定值得记)',
1469
+ '- 项目专属 → [PROJECT];跨项目通用的用户硬性规则/偏好 → [USER]',
1470
+ '- 每条一句话,简短明确;已经在下方"已有记忆"里出现的不要重复',
1471
+ '输出格式(严格遵守):',
1472
+ '[PROJECT]',
1473
+ '- 要点1',
1474
+ '- 要点2',
1475
+ '[USER]',
1476
+ '- 规则1',
1477
+ '若没有任何值得长期记录的内容,只输出一行:(无)',
1478
+ '',
1479
+ '最近 ' + days + ' 天日志:',
1480
+ recent.map((r) => '### ' + r.date + '\n' + r.text).join('\n\n'),
1481
+ '',
1482
+ '已有项目笔记(尾部):',
1483
+ truncateTail(this.state.notesText || '', 1200) || '(空)',
1484
+ '',
1485
+ '已有用户级记忆(尾部):',
1486
+ truncateTail(this.state.userText || '', 1200) || '(空)',
1487
+ ].join('\n')
1488
+ const text = await this.runSubagent(prompt, 'auto-memory-consolidate-logs', agent)
1489
+ if (!text) return 'memory_consolidate: AI 提炼失败(超时或 subagent 不可用),未写入任何内容。'
1490
+ if (text.includes('(无)')) return 'memory_consolidate: AI 判断最近日志没有值得长期记录的新内容。'
1491
+ const projectPts = []
1492
+ const userPts = []
1493
+ let section = ''
1494
+ for (const raw of text.split('\n')) {
1495
+ const l = raw.trim()
1496
+ if (!l) continue
1497
+ if (l === '[PROJECT]') { section = 'project'; continue }
1498
+ if (l === '[USER]') { section = 'user'; continue }
1499
+ if (l.startsWith('- ') && section) {
1500
+ const pt = l.slice(2).trim()
1501
+ if (pt) { if (section === 'project') projectPts.push(pt); else userPts.push(pt) }
1502
+ }
1503
+ }
1504
+ const today = this.memToday()
1505
+ const written = []
1506
+ const skipped = []
1507
+ if (projectPts.length) {
1508
+ const r = await this.ensureBudget(agent, 'note', projectPts.map((x) => '- ' + x).join('\n'))
1509
+ if (r.ok) {
1510
+ const body = await this.appendText(p.notesPath, '\n## ' + today + '\n' + projectPts.map((x) => '- ' + x).join('\n'))
1511
+ this.state.notesText = body
1512
+ written.push('项目笔记 ' + projectPts.length + ' 条')
1513
+ } else skipped.push('项目笔记(预算超限且压缩不可用)')
1514
+ }
1515
+ if (userPts.length) {
1516
+ const r = await this.ensureBudget(agent, 'user', userPts.map((x) => '- ' + x).join('\n'))
1517
+ if (r.ok) {
1518
+ const body = await this.appendText(p.userFile, '\n## ' + today + '\n' + userPts.map((x) => '- ' + x).join('\n'))
1519
+ this.state.userText = body
1520
+ written.push('用户级记忆 ' + userPts.length + ' 条')
1521
+ } else skipped.push('用户级记忆(预算超限且压缩不可用)')
1522
+ }
1523
+ this.state.loadedAt = Date.now()
1524
+ if (!written.length) {
1525
+ if (skipped.length) return 'memory_consolidate: AI 已提炼,但今日写入预算已用尽,以下层被跳过(明天再固化): ' + skipped.join('、') + '。'
1526
+ return 'memory_consolidate: AI 未提炼出值得长期记录的内容。'
1527
+ }
1528
+ const detail = []
1529
+ if (projectPts.length) detail.push('项目笔记新增:\n' + projectPts.map((x) => '- ' + x).join('\n'))
1530
+ if (userPts.length) detail.push('用户级记忆新增:\n' + userPts.map((x) => '- ' + x).join('\n'))
1531
+ if (skipped.length) detail.push('被今日预算跳过: ' + skipped.join('、'))
1532
+ return 'memory_consolidate 完成,已固化 ' + written.join('、') + '(带日期标题)。\n' + detail.join('\n\n')
1533
+ }
1534
+
626
1535
  /** 一键反思:自动取"有日志但无反思"的最早日期,按日志条目生成反思草稿并落盘。 */
627
1536
  async reflectAuto(agent) {
628
1537
  const p = await this.resolvePaths(agent)
@@ -645,6 +1554,7 @@ class MemoryEngine {
645
1554
  }
646
1555
 
647
1556
  // ---------- 维护 ----------
1557
+ /** 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档到 archive/,活跃日志移除。 */
648
1558
  async maintain(days = 30, agent) {
649
1559
  const p = await this.resolvePaths(agent)
650
1560
  const cutoff = new Date()
@@ -655,18 +1565,63 @@ class MemoryEngine {
655
1565
  if (!m) return false
656
1566
  return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) < cutoff
657
1567
  })
658
- if (!oldLogs.length) return '没有超过 ' + days + ' 天的日志,无需归档。'
659
- let archive = '\n## 归档日志(归档于 ' + todayStr() + ')'
660
- let archivedBytes = 0
1568
+ if (!oldLogs.length) return '没有超过 ' + days + ' 天的日志,无需蒸馏。'
1569
+ // 1) AI 蒸馏:提炼有长期价值的要点(不可用则降级为原样归档)
1570
+ let distillText = ''
1571
+ if (this._subagents && agent) {
1572
+ try {
1573
+ const digest = []
1574
+ for (const log of oldLogs) {
1575
+ const text = await this.readTextSafe(path.join(p.projectDir, log.name))
1576
+ if (text && text.trim()) digest.push('### ' + log.name + '\n' + truncateTail(text, 2000))
1577
+ }
1578
+ if (digest.length) {
1579
+ const prompt = [
1580
+ '你是用户的记忆管家。以下日志已超过 ' + days + ' 天,请把它们蒸馏成值得长期记住的要点。',
1581
+ '规则:',
1582
+ '- 只提炼有跨会话长期价值的:技术决策、架构约定、关键路径、用户偏好/习惯、踩过的坑及其规则',
1583
+ '- 丢弃过程流水(某次具体修 bug 的过程、临时路径、搜索结果、报错细节)',
1584
+ '- 按主题组织,可用"### 主题"小标题,每条一句话,简短明确',
1585
+ '- 不要复述日志原文,只要蒸馏后的要点',
1586
+ '输出格式:markdown(### 主题 + - 要点)。若没有任何值得保留的内容,只输出一行:(无)',
1587
+ '',
1588
+ '待蒸馏日志:',
1589
+ digest.join('\n\n'),
1590
+ ].join('\n')
1591
+ const out = await this.runSubagent(prompt, 'auto-memory-distill', agent)
1592
+ if (out && !out.includes('(无)')) distillText = out.trim().slice(0, 3000)
1593
+ }
1594
+ } catch (e) {
1595
+ console.error('[dsh-auto-memory] maintain distill failed', e && e.message ? e.message : e)
1596
+ }
1597
+ }
1598
+ // 2) 原文保底归档到 archive/(不占注入窗口,绝不丢信息)
1599
+ const archiveDir = path.join(p.projectDir, 'archive')
1600
+ await mkdir(archiveDir, { recursive: true })
1601
+ const archived = []
661
1602
  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
1603
+ try {
1604
+ const text = await this.readTextSafe(path.join(p.projectDir, log.name))
1605
+ if (text) { await this.writeFull(path.join(archiveDir, log.name), text); archived.push(log.name) }
1606
+ } catch (e) {}
1607
+ }
1608
+ // 3) 蒸馏结果写项目笔记;无 AI 时原样归档段保底
1609
+ let noteMsg = ''
1610
+ if (distillText) {
1611
+ const body = await this.appendText(p.notesPath, '\n## 30 天蒸馏(蒸馏于 ' + this.memToday() + ')\n' + distillText)
1612
+ this.state.notesText = body
1613
+ noteMsg = '\n蒸馏提炼已写入 ' + p.notesPath + ':\n' + truncateTail(distillText, 600)
1614
+ } else if (archived.length) {
1615
+ let archive = '\n## 归档日志(归档于 ' + this.memToday() + ')'
1616
+ for (const name of archived) {
1617
+ const t = await this.readTextSafe(path.join(archiveDir, name))
1618
+ if (t) archive += '\n\n### ' + name + '\n' + t
666
1619
  }
1620
+ const body = await this.appendText(p.notesPath, archive)
1621
+ this.state.notesText = body
1622
+ noteMsg = '\nAI 蒸馏不可用,原文已按老方式归档到 ' + p.notesPath
667
1623
  }
668
- const notesBody = await this.appendText(p.notesPath, archive)
669
- this.state.notesText = notesBody
1624
+ // 4) 活跃目录移除旧日志(原文已在 archive/ 保底)
670
1625
  const deleted = []
671
1626
  const kept = []
672
1627
  for (const log of oldLogs) {
@@ -676,8 +1631,9 @@ class MemoryEngine {
676
1631
  } catch (e) { kept.push(log.name) }
677
1632
  }
678
1633
  this.state.loadedAt = Date.now()
679
- return '已归档 ' + oldLogs.length + ' 个日志文件(' + archivedBytes + ' 字符)' + p.notesPath +
680
- (deleted.length ? '\n已删除: ' + deleted.join(', ') : '') +
1634
+ return '30 天蒸馏完成:' + (distillText ? ' AI 提炼 + 原文归档' : ' 原文归档') + ' ' + oldLogs.length + ' 个旧日志(' + deleted.join(', ') + ')' +
1635
+ '\n原文保底: ' + archiveDir + ' (' + archived.length + ' 个文件)' +
1636
+ noteMsg +
681
1637
  (kept.length ? '\n未删除(可手动清理): ' + kept.join(', ') : '')
682
1638
  }
683
1639
 
@@ -703,6 +1659,7 @@ class MemoryEngine {
703
1659
  todayEntries,
704
1660
  latestReflectionDate: this.state.latestReflectionDate,
705
1661
  pendingReflection: this.state.pendingReflection ? this.state.pendingReflection.date : undefined,
1662
+ autoStats: this.autoStats,
706
1663
  greeting: this.greetingData(),
707
1664
  calendar: this.parseCalendar(this.state.calendarText),
708
1665
  calendarPath: this.state.calendarPath,
@@ -785,12 +1742,12 @@ function isUnderMemoryTree(engine, target) {
785
1742
  // ═══════════════════════════════════════════════════════════════════════════
786
1743
  // 外部记忆接入(其他 AI 工具的记忆继承)
787
1744
  //
788
- // 目标:让 DSH 继承用户在 CodeBuddy / Claude Code / Codex / Cursor 等
789
- // 工具中积累的记忆,持续拟合用户画像。
1745
+ // 目标:让 DSH 继承用户在 AI 助手 / CodeBuddy / Claude Code / Codex /
1746
+ // Cursor 等工具中积累的记忆,持续拟合用户画像。
790
1747
  //
791
1748
  // 源分三类:
792
1749
  // - markdown 记忆(用户级/画像/项目约定):内容小,直接读入缓存,注入/检索/接入
793
- // - 会话日志(jsonl,各工具 projects / sessions):
1750
+ // - 会话日志(jsonl,AI 助手 projects / Claude projects / Codex sessions):
794
1751
  // 只列索引,检索时按需扫描(行数/文件数上限),绝不整库注入
795
1752
  // ═══════════════════════════════════════════════════════════════════════════
796
1753
  class ExternalMemory {
@@ -987,7 +1944,7 @@ class ExternalMemory {
987
1944
  const src = srcs.find((s) => s.id === sourceId)
988
1945
  if (!src) return '外部源不存在: ' + sourceId
989
1946
  if (src.kind === 'sessions') return '会话类源不支持整体接入,请用 memory_recall 按需检索(' + src.files.length + ' 个会话文件)。'
990
- const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + todayStr()
1947
+ const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + this.memToday()
991
1948
  if (target === 'user') {
992
1949
  const p = await engine.resolvePaths(agent)
993
1950
  const body = await engine.appendText(p.userFile, '\n' + stamp + '\n' + src.content.slice(0, 120000))
@@ -1013,6 +1970,33 @@ class ExternalMemory {
1013
1970
  }
1014
1971
  }
1015
1972
 
1973
+ /** 从 agent.session 提取模型可见消息序列 [{role,text,sourceKind}]。 */
1974
+ function extractSessionMessages(agent) {
1975
+ try {
1976
+ const session = agent && agent.session
1977
+ if (!session) return []
1978
+ const nodes = session.surface && session.surface.nodes
1979
+ if (!nodes) return []
1980
+ const seqs = Array.isArray(nodes) ? nodes : (nodes instanceof Set ? Array.from(nodes) : [])
1981
+ if (!seqs.length) return []
1982
+ const events = session.events || []
1983
+ const out = []
1984
+ for (const seq of seqs) {
1985
+ const ev = events[seq]
1986
+ if (!ev) continue
1987
+ let msg = null
1988
+ if (ev.type === 'user/message') msg = ev.data
1989
+ else if (ev.type === 'assistant/message' || ev.type === 'tool/result') msg = ev.data && ev.data.message
1990
+ if (!msg || !msg.role || !Array.isArray(msg.content)) continue
1991
+ const text = msg.content
1992
+ .filter((b) => b && typeof b === 'object' && typeof b.text === 'string')
1993
+ .map((b) => b.text).join('')
1994
+ out.push({ role: msg.role, text, sourceKind: msg.source && msg.source.kind })
1995
+ }
1996
+ return out
1997
+ } catch (e) { return [] }
1998
+ }
1999
+
1016
2000
  /** 递归收集目录下匹配正则的文件(上限 n)。 */
1017
2001
  async function globOne(dir, re, limit) {
1018
2002
  const out = []
@@ -1031,7 +2015,7 @@ async function globOne(dir, re, limit) {
1031
2015
  return out
1032
2016
  }
1033
2017
 
1034
- /** 从一条 jsonl 会话行提取文本片段(兼容 claude/codex 等格式)。 */
2018
+ /** 从一条 jsonl 会话行提取文本片段(兼容 claude/codex/workbuddy 格式)。 */
1035
2019
  function extractJsonText(line) {
1036
2020
  try {
1037
2021
  const obj = JSON.parse(line)
@@ -1068,11 +2052,36 @@ export function apply(ctx, config) {
1068
2052
  // 生命周期刷新
1069
2053
  const refreshAll = (agent) => { void engine.refresh(agent) }
1070
2054
  refreshAll()
1071
- ctx.on('agent/session-start', (payload) => refreshAll(payload && payload.agent))
2055
+ ctx.on('agent/session-start', (payload) => {
2056
+ refreshAll(payload && payload.agent)
2057
+ // 缓存最近 agent 引用(subagent parent 需要完整 agent 对象)
2058
+ try { if (payload && payload.agent) engine._lastAgent = payload.agent } catch (e) {}
2059
+ })
1072
2060
  ctx.on('agent/turn-stopping', (payload) => {
1073
2061
  refreshAll(payload && payload.agent)
1074
2062
  // 记录最后活动时间(判断暂离回来用)
1075
2063
  try { engine._lastActiveAt = Date.now() } catch (e) {}
2064
+ // 每轮自动沉淀:取本轮消息 → subagent 判断/提炼 → 写今日日志([自动沉淀])+升格长期记忆
2065
+ // 注意:turn-stopping payload 只有 {turn, signal},没有 agent 字段,必须用 _lastAgent 兜底
2066
+ try {
2067
+ const agent = (payload && payload.agent) || engine._lastAgent
2068
+ if (agent && agent.session) void engine.consolidateTurn(payload.turn, agent)
2069
+ } catch (e) {}
2070
+ })
2071
+ // 注入层保障:systemPrompt section.text 是同步函数不能 await,state 异步加载会导致首轮注入为空。
2072
+ // pre-step 是 waterfall(可 await),在放行每个 step 前条件性刷新:
2073
+ // 首轮(loadedAt=0)必定 await 完 → 模型从第一个 token 起就看到记忆;之后每 15s 跟进轮间新写入。
2074
+ ctx.on('agent/pre-step', async (payload, next) => {
2075
+ try {
2076
+ const agent = (payload && payload.agent) || engine._lastAgent
2077
+ // 只刷新顶层会话:子代理(自动沉淀/固化的 subagent)session 无 cwd,刷新会把 state 切到错误工作区
2078
+ let skip = false
2079
+ try { if (agent && agent.session && agent.session.header && agent.session.header.parentSession) skip = true } catch (e) {}
2080
+ if (!skip && agent && (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 15000)) {
2081
+ await engine.refresh(agent)
2082
+ }
2083
+ } catch (e) {}
2084
+ return next()
1076
2085
  })
1077
2086
 
1078
2087
  // ---------- 系统提示词注入 ----------
@@ -1083,7 +2092,7 @@ export function apply(ctx, config) {
1083
2092
  try {
1084
2093
  const agent = context && context.agent
1085
2094
  if (!agent) return ''
1086
- if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 60000) {
2095
+ if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 15000) {
1087
2096
  void engine.refresh(agent)
1088
2097
  }
1089
2098
  return engine.renderMemory(context) + engine.renderReflectionRequest()
@@ -1097,57 +2106,61 @@ export function apply(ctx, config) {
1097
2106
  note: { type: 'string', required: true, description: '简短条目:一句话概括做了什么、结果如何。' },
1098
2107
  date: { type: 'string', description: '日志日期 YYYY-MM-DD,缺省今天。' },
1099
2108
  }, async (args, exec) => {
1100
- const date = DATE_RE.test(args.date || '') ? args.date : todayStr()
2109
+ const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
1101
2110
  const p = await engine.resolvePaths(exec.agent)
1102
2111
  const logPath = path.join(p.projectDir, date + '.md')
1103
2112
  const entry = '- ' + nowHm() + ' ' + String(args.note).trim()
1104
2113
  const body = await engine.appendText(logPath, entry)
1105
- if (date === todayStr()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
2114
+ if (date === engine.memToday()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
1106
2115
  return '已更新记忆文档: ' + logPath + '\n' + entry
1107
2116
  }),
1108
2117
 
1109
- defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点)。action=append 追加一段(自动带日期标题);action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容)。**调用后必须在本轮回复正文中向用户转述:更新了项目笔记、加入什么要点**。', {
2118
+ defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点)。action=append 追加一段(自动带日期标题);action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容)。每日预算 3000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:更新了项目笔记、加入什么要点**。', {
1110
2119
  content: { type: 'string', required: true, description: '笔记内容。' },
1111
2120
  action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
1112
2121
  }, async (args, exec) => {
1113
2122
  const p = await engine.resolvePaths(exec.agent)
1114
2123
  const content = String(args.content || '').trim()
1115
2124
  if (!content) return 'memory_note: content 为空,未写入。'
2125
+ const acct = await engine.ensureBudget(exec.agent, 'note', content)
2126
+ if (!acct.ok) return 'memory_note: 项目笔记今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
1116
2127
  let body
1117
2128
  if (args.action === 'replace') {
1118
2129
  body = content
1119
2130
  await engine.writeFull(p.notesPath, body)
1120
2131
  } else {
1121
- body = await engine.appendText(p.notesPath, '\n## ' + todayStr() + '\n' + content)
2132
+ body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + content)
1122
2133
  }
1123
2134
  engine.state.notesText = body; engine.state.loadedAt = Date.now()
1124
- return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + content
2135
+ return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
1125
2136
  }),
1126
2137
 
1127
- defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
2138
+ defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。每日预算 4000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
1128
2139
  content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
1129
2140
  action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
1130
2141
  }, async (args, exec) => {
1131
2142
  const p = await engine.resolvePaths(exec.agent)
1132
2143
  const content = String(args.content || '').trim()
1133
2144
  if (!content) return 'memory_user: content 为空,未写入。'
2145
+ const acct = await engine.ensureBudget(exec.agent, 'user', content)
2146
+ if (!acct.ok) return 'memory_user: 用户级记忆今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
1134
2147
  let body
1135
2148
  if (args.action === 'replace') {
1136
2149
  body = content
1137
2150
  await engine.writeFull(p.userFile, body)
1138
2151
  } else {
1139
- body = await engine.appendText(p.userFile, '\n## ' + todayStr() + '\n' + content)
2152
+ body = await engine.appendText(p.userFile, '\n## ' + engine.memToday() + '\n' + content)
1140
2153
  }
1141
2154
  engine.state.userText = body; engine.state.loadedAt = Date.now()
1142
- return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content
2155
+ return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
1143
2156
  }),
1144
2157
 
1145
- defineTool('memory_recall', '检索记忆:本地记忆文件(每日日志、项目笔记、用户级记忆、反思)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。用户提到过去的做法/讨论/决定而当前上下文没有时调用,查询必须自包含。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
2158
+ defineTool('memory_recall', '检索记忆:本地记忆文件(当前工作区 + 其他所有工作区的每日日志、项目笔记、用户级记忆、反思)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。开发/排查中不懂的、用户提到过去的做法/讨论/决定而当前上下文没有时调用——跨工作区的记忆也能检索到(结果标注来源工作区)。查询必须自包含。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
1146
2159
  query: { type: 'string', required: true, description: '检索关键词或自包含描述。' },
1147
2160
  limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
1148
2161
  }, async (args, exec) => engine.recall(args.query, args.limit, exec.agent)),
1149
2162
 
1150
- defineTool('memory_maintain', '维护记忆:把 days(缺省30)天前的 .dsh-memory/ 每日日志原样归档进项目 MEMORY.md 的归档段,并删除旧日志文件。归档保底不丢信息,之后可按返回结果决定是否精简归档段。', {
2163
+ defineTool('memory_maintain', '维护记忆(30 天蒸馏):把 days(缺省30)天前的 .dsh-memory/ 每日日志交给 AI 蒸馏提炼出有长期价值的要点写入项目 MEMORY.md,原文保底归档到 .dsh-memory/archive/ 后从活跃日志移除。AI 不可用时降级为原样归档,不丢信息。', {
1151
2164
  days: { type: 'integer', description: '归档阈值天数,缺省 30。' },
1152
2165
  }, async (args, exec) => engine.maintain(args.days, exec.agent)),
1153
2166
 
@@ -1168,14 +2181,14 @@ export function apply(ctx, config) {
1168
2181
  text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
1169
2182
  }, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
1170
2183
 
1171
- defineTool('memory_external', '查看/接入其他 AI 工具(CodeBuddy/Claude Code/Codex/项目约定文件等)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 把某源内容整体接入本地记忆(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,自动标注来源)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
2184
+ defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 把某源内容整体接入本地记忆(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,自动标注来源)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
1172
2185
  action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
1173
2186
  source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
1174
2187
  target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
1175
2188
  }, async (args, exec) => {
1176
2189
  if (args.action === 'list') {
1177
2190
  const list = await engine.external.summarize()
1178
- if (!list.length) return '未检测到其他 AI 工具的记忆文件(可检查 ~/.codebuddy、~/.claude、~/.codex 等目录是否存在)。'
2191
+ if (!list.length) return '未检测到其他 AI 工具的记忆文件(可检查 ~/.workbuddy、~/.codebuddy、~/.claude、~/.codex 是否存在)。'
1179
2192
  const lines = []
1180
2193
  lines.push('检测到 ' + list.length + ' 个外部记忆源:')
1181
2194
  for (const s of list) {
@@ -1222,6 +2235,10 @@ export function apply(ctx, config) {
1222
2235
  time: { type: 'string', required: true, description: '时间 HH:MM 或 --:--。' },
1223
2236
  title: { type: 'string', required: true, description: '事项标题。' },
1224
2237
  }, async (args, exec) => engine.calendarRemove(args.date, args.time, args.title, exec.agent)),
2238
+
2239
+ defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。适合隔一段时间主动调用一次;每轮对话结束的自动沉淀也基于同一套提炼逻辑。', {
2240
+ days: { type: 'integer', description: '读取最近 N 天日志,缺省 7,上限 30。' },
2241
+ }, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
1225
2242
  ]
1226
2243
 
1227
2244
  // ---------- 路由 ----------
@@ -1232,7 +2249,13 @@ export function apply(ctx, config) {
1232
2249
  handler: async (req, res) => {
1233
2250
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
1234
2251
  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) }) }
2252
+ try {
2253
+ // 优先用前端传来的当前工作区(客户端从 ctx.sessions.list 拿),回退最近活跃 agent
2254
+ const url = new URL(req.url || '/', 'http://localhost')
2255
+ const ws = url.searchParams.get('ws') || ''
2256
+ await engine.refresh(ws ? { session: { header: { cwd: ws } } } : engine._lastAgent)
2257
+ writeJson(res, 200, await engine.snapshot())
2258
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
1236
2259
  },
1237
2260
  },
1238
2261
  {
@@ -1287,6 +2310,86 @@ export function apply(ctx, config) {
1287
2310
  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
2311
  },
1289
2312
  },
2313
+ {
2314
+ kind: 'exact',
2315
+ path: API.smartRecall,
2316
+ handler: async (req, res) => {
2317
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2318
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2319
+ const body = await readJsonBody(req)
2320
+ if (!body || typeof body.query !== 'string') return writeJson(res, 400, { error: 'invalid body' })
2321
+ try {
2322
+ await engine.refresh(undefined)
2323
+ writeJson(res, 200, await engine.smartRecall(body.query))
2324
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2325
+ },
2326
+ },
2327
+ {
2328
+ kind: 'exact',
2329
+ path: API.workspaces,
2330
+ handler: async (req, res) => {
2331
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2332
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2333
+ const body = await readJsonBody(req).catch(() => ({}))
2334
+ try {
2335
+ writeJson(res, 200, await engine.workspaceOverview(undefined, !!(body && body.force)))
2336
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2337
+ },
2338
+ },
2339
+ {
2340
+ kind: 'exact',
2341
+ path: API.debug,
2342
+ handler: async (req, res) => {
2343
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2344
+ if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
2345
+ try { writeJson(res, 200, await engine.debugInfo()) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2346
+ },
2347
+ },
2348
+ {
2349
+ kind: 'exact',
2350
+ path: API.browseDir,
2351
+ handler: async (req, res) => {
2352
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2353
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2354
+ const body = await readJsonBody(req).catch(() => ({}))
2355
+ try {
2356
+ let p = String((body && body.path) || homedir() || process.cwd())
2357
+ if (p === '~') p = homedir()
2358
+ if (p.startsWith('~/') || p.startsWith('~\\')) p = path.join(homedir(), p.slice(2))
2359
+ const st = await stat(p)
2360
+ if (!st.isDirectory()) p = path.dirname(p)
2361
+ const entries = await readdir(p, { withFileTypes: true })
2362
+ const dirs = entries
2363
+ .filter((en) => en.isDirectory() && !en.name.startsWith('.'))
2364
+ .map((en) => ({ name: en.name, path: path.join(p, en.name) }))
2365
+ .sort((a, b) => (a.name < b.name ? -1 : 1))
2366
+ .slice(0, 300)
2367
+ writeJson(res, 200, { path: p, parent: path.dirname(p), dirs })
2368
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2369
+ },
2370
+ },
2371
+ {
2372
+ kind: 'exact',
2373
+ path: API.pickDir,
2374
+ handler: async (req, res) => {
2375
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2376
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2377
+ // 调用系统的文件夹选择器(native 后端,由 dsh 的 directory-picker-auto 按主机情况挂载);不可用则返回 native:false,前端回退内嵌浏览
2378
+ try {
2379
+ let picker = undefined
2380
+ try { picker = ctx.directoryPicker } catch (e) {}
2381
+ if (!picker || typeof picker.capability !== 'function') return writeJson(res, 200, { native: false })
2382
+ const cap = picker.capability()
2383
+ if (!cap || cap.kind !== 'native' || typeof cap.pick !== 'function') return writeJson(res, 200, { native: false })
2384
+ const ac = new AbortController()
2385
+ const timer = setTimeout(() => { try { ac.abort() } catch (e) {} }, 5 * 60 * 1000)
2386
+ try {
2387
+ const dir = await cap.pick(ac.signal)
2388
+ writeJson(res, 200, { native: true, dir: dir || null })
2389
+ } finally { clearTimeout(timer) }
2390
+ } catch (e) { writeJson(res, 200, { native: true, dir: null, error: String(e && e.message ? e.message : e) }) }
2391
+ },
2392
+ },
1290
2393
  {
1291
2394
  kind: 'exact',
1292
2395
  path: API.config,
@@ -1304,7 +2407,8 @@ export function apply(ctx, config) {
1304
2407
  const allowed = Object.keys(DEFAULT_CONFIG)
1305
2408
  const patch = {}
1306
2409
  for (const key of allowed) if (body[key] !== undefined) patch[key] = body[key]
1307
- writeJson(res, 200, { config: await engine.saveConfig(patch) })
2410
+ const saved = await engine.saveConfig(patch)
2411
+ writeJson(res, 200, { config: saved.config, migrated: saved.migrated || '' })
1308
2412
  return
1309
2413
  }
1310
2414
  writeJson(res, 405, { error: 'method not allowed' })
@@ -1321,7 +2425,7 @@ export function apply(ctx, config) {
1321
2425
  if (!body || typeof body.content !== 'string' || !body.content.trim()) return writeJson(res, 400, { error: 'invalid body' })
1322
2426
  try {
1323
2427
  const p = await engine.resolvePaths(undefined)
1324
- const text = '\n## ' + todayStr() + '\n' + body.content.trim()
2428
+ const text = '\n## ' + engine.memToday() + '\n' + body.content.trim()
1325
2429
  const updated = await engine.appendText(p.notesPath, text)
1326
2430
  engine.state.notesText = updated
1327
2431
  engine.state.loadedAt = Date.now()
@@ -1404,17 +2508,69 @@ export function apply(ctx, config) {
1404
2508
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
1405
2509
  const body = await readJsonBody(req)
1406
2510
  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) }) }
2511
+ try {
2512
+ const ws = body.ws || ''
2513
+ await engine.refresh(ws ? { session: { header: { cwd: ws } } } : undefined)
2514
+ const out = await engine.summarizePeriod(body.period, undefined, !!body.force)
2515
+ writeJson(res, 200, { ...out, result: out.summary })
2516
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2517
+ },
2518
+ },
2519
+ {
2520
+ kind: 'exact',
2521
+ path: API.greet,
2522
+ handler: async (req, res) => {
2523
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
2524
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
2525
+ try {
2526
+ const ws = body && body.ws ? body.ws : ''
2527
+ await engine.refresh(ws ? { session: { header: { cwd: ws } } } : undefined)
2528
+ writeJson(res, 200, await engine.greetToday())
2529
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
1408
2530
  },
1409
2531
  },
1410
2532
  ]
1411
2533
 
2534
+ // ---------- 后台轮询兜底(对标外部助手的心跳轮询):每 5 分钟重试失败的自动沉淀 + 心跳文件 ----------
2535
+ // 心跳:每次轮询把存活状态写入 ~/.dsh/memory/polling-heartbeat.json(可随时查看 LastWriteTime 确认轮询活着)
2536
+ const writeHeartbeat = async () => {
2537
+ try {
2538
+ const q = engine._pendingConsolidations || []
2539
+ const hb = path.join(dshHome(), 'memory', 'polling-heartbeat.json')
2540
+ await mkdir(path.dirname(hb), { recursive: true })
2541
+ await writeFile(hb, JSON.stringify({
2542
+ pid: process.pid,
2543
+ heartbeatAt: Date.now(),
2544
+ uptimeMs: Math.round(process.uptime() * 1000),
2545
+ queueLength: q.length,
2546
+ lastConsolidatedAt: (engine.autoStats && engine.autoStats.lastAt) || 0,
2547
+ todayCount: (engine.autoStats && engine.autoStats.count) || 0,
2548
+ }, null, 2), 'utf8')
2549
+ } catch (e) {}
2550
+ }
2551
+ const retryTimer = setInterval(() => {
2552
+ void (async () => {
2553
+ try {
2554
+ const q = engine._pendingConsolidations
2555
+ if (q && q.length && !engine._consolidating) {
2556
+ const item = q.shift()
2557
+ if (item && item.agent) void engine.consolidateTurn(item.turn, item.agent)
2558
+ }
2559
+ } catch (e) {}
2560
+ })()
2561
+ }, 5 * 60 * 1000)
2562
+ // 心跳独立 15 秒一次(对齐 polling-lease 心跳节奏),证明轮询机制活着
2563
+ const heartbeatTimer = setInterval(() => { void writeHeartbeat() }, 15000)
2564
+ void writeHeartbeat() // 立即心跳一次:重启后马上可见轮询存活
2565
+
1412
2566
  // ---------- 注册与清理 ----------
1413
2567
  const disposers = []
1414
2568
  disposers.push(disposeSection)
1415
2569
  for (const tool of tools) disposers.push(ctx.tools.register(tool))
1416
2570
  for (const route of routes) disposers.push(ctx.webServer.register(route))
1417
2571
  ctx.effect(() => () => {
2572
+ clearInterval(retryTimer)
2573
+ clearInterval(heartbeatTimer)
1418
2574
  for (const dispose of disposers) { try { dispose() } catch (e) {} }
1419
2575
  }, 'dsh-auto-memory: surfaces')
1420
2576