@a9i5k4/dsh-auto-memory 0.1.7 → 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/README.md +160 -17
- package/README.zh-CN.md +158 -15
- package/lib/client.js +367 -43
- package/lib/index.js +1266 -58
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-auto-memory — host half.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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,26 +11,29 @@
|
|
|
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'
|
|
25
28
|
|
|
26
29
|
/** Services required before the memory surfaces can mount. */
|
|
27
|
-
export const inject = ['webServer', 'tools', 'systemPrompt']
|
|
30
|
+
export const inject = ['webServer', 'tools', 'systemPrompt', 'subagents']
|
|
28
31
|
|
|
29
|
-
/** Prompt order of the memory section
|
|
30
|
-
const SECTION_ORDER =
|
|
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
|
|
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',
|
|
@@ -45,6 +53,8 @@ export const API = {
|
|
|
45
53
|
external: '/api/dsh-auto-memory/external',
|
|
46
54
|
'external-import': '/api/dsh-auto-memory/external-import',
|
|
47
55
|
calendar: '/api/dsh-auto-memory/calendar',
|
|
56
|
+
summarize: '/api/dsh-auto-memory/summarize',
|
|
57
|
+
greet: '/api/dsh-auto-memory/greet',
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
const DEFAULT_CONFIG = {
|
|
@@ -52,12 +62,20 @@ const DEFAULT_CONFIG = {
|
|
|
52
62
|
userMemoryDir: '~/.dsh/memory',
|
|
53
63
|
/** 项目级记忆目录名(相对工作区)。 */
|
|
54
64
|
projectMemoryDir: '.dsh-memory',
|
|
65
|
+
/** 集中式记忆根目录(集中式):所有工作区的记忆统一存放,每工作区一个子目录(旧版分散在各工作区 .dsh-memory/ 会自动迁移)。 */
|
|
66
|
+
memoryRoot: '~/.dsh/memory/workspaces',
|
|
55
67
|
/** 是否注入记忆上下文。 */
|
|
56
68
|
injectEnabled: true,
|
|
57
69
|
/** 注入总预算(字符)。 */
|
|
58
70
|
injectBudgetChars: 2400,
|
|
59
71
|
/** 注入的最近日志天数。 */
|
|
60
72
|
recentDaysInjected: 3,
|
|
73
|
+
/** 每轮对话结束自动沉淀记忆(subagent 判断+提炼,有 API 成本;默认开)。 */
|
|
74
|
+
autoConsolidate: true,
|
|
75
|
+
/** 自动沉淀内容门槛:本轮 user+assistant 文本总字符数低于此值视为寒暄,跳过。 */
|
|
76
|
+
autoConsolidateMinChars: 60,
|
|
77
|
+
/** 日界(分钟,从 0 点起算):凌晨在此之前的活儿归前一天日志;默认 450=早上 7:30 后才进入新一天。 */
|
|
78
|
+
dayBoundaryMinutes: 450,
|
|
61
79
|
/** 是否启用每日反思。 */
|
|
62
80
|
reflectEnabled: true,
|
|
63
81
|
/** 反思风格: auto=由内容决定 / life=生活化 / professional=专业性。 */
|
|
@@ -66,7 +84,7 @@ const DEFAULT_CONFIG = {
|
|
|
66
84
|
locale: 'zh',
|
|
67
85
|
/** 外部记忆注入预算(字符)。 */
|
|
68
86
|
externalInjectionChars: 1400,
|
|
69
|
-
/** 外部记忆源开关(CodeBuddy/Claude Code/Codex
|
|
87
|
+
/** 外部记忆源开关(AI 助手/CodeBuddy/Claude Code/Codex/项目约定)。 */
|
|
70
88
|
externalSources: {
|
|
71
89
|
'workbuddy-user': true,
|
|
72
90
|
'workbuddy-profile': true,
|
|
@@ -83,6 +101,7 @@ const DEFAULT_CONFIG = {
|
|
|
83
101
|
const pad = (n) => String(n).padStart(2, '0')
|
|
84
102
|
const todayStr = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
|
|
85
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())}` }
|
|
86
105
|
const dateStrOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
|
|
87
106
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
88
107
|
const truncateHead = (s, n) => (s && s.length > n) ? s.slice(0, n) + '\n…(截断,完整内容用 memory_recall 或 GUI 面板)' : (s || '')
|
|
@@ -113,6 +132,11 @@ class MemoryEngine {
|
|
|
113
132
|
}
|
|
114
133
|
this._configPath = path.join(dshHome(), 'dsh-auto-memory.json')
|
|
115
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 即时反馈)
|
|
116
140
|
this.external = new ExternalMemory(this)
|
|
117
141
|
}
|
|
118
142
|
|
|
@@ -132,12 +156,48 @@ class MemoryEngine {
|
|
|
132
156
|
|
|
133
157
|
async saveConfig(patch) {
|
|
134
158
|
await this.loadConfig()
|
|
159
|
+
const oldRoot = this.expandUserPath(this.config.memoryRoot)
|
|
160
|
+
const oldUser = this.expandUserPath(this.config.userMemoryDir)
|
|
135
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) }
|
|
136
196
|
await mkdir(path.dirname(this._configPath), { recursive: true })
|
|
137
197
|
await writeFile(this._configPath, JSON.stringify(this.config, null, 2), 'utf8')
|
|
138
198
|
this.state.loadedAt = 0 // 强制重载(目录可能变化)
|
|
139
199
|
await this.refresh(undefined)
|
|
140
|
-
return this.config
|
|
200
|
+
return { config: this.config, migrated }
|
|
141
201
|
}
|
|
142
202
|
|
|
143
203
|
// ---------- 路径 ----------
|
|
@@ -148,9 +208,46 @@ class MemoryEngine {
|
|
|
148
208
|
return path.resolve(p)
|
|
149
209
|
}
|
|
150
210
|
|
|
211
|
+
/** 工作区路径 → 记忆子目录名(与 ~/.dsh/sessions 目录风格一致,可读且唯一)。 */
|
|
212
|
+
wsKey(ws) {
|
|
213
|
+
if (!ws) return 'default'
|
|
214
|
+
return '--' + String(ws).replace(/[\\/:*?"<>|]/g, '-') + '--'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 集中式记忆根目录(集中式):所有工作区记忆统一存放,每工作区一个子目录。 */
|
|
151
218
|
projectDirOf(ws) {
|
|
152
219
|
const name = this.config.projectMemoryDir || '.dsh-memory'
|
|
153
|
-
|
|
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) }
|
|
154
251
|
}
|
|
155
252
|
|
|
156
253
|
userDirOf() {
|
|
@@ -170,10 +267,11 @@ class MemoryEngine {
|
|
|
170
267
|
calendarPath: path.join(userDir, 'CALENDAR.md'),
|
|
171
268
|
projectDir,
|
|
172
269
|
notesPath: path.join(projectDir, 'MEMORY.md'),
|
|
173
|
-
logPath: path.join(projectDir, `${
|
|
270
|
+
logPath: path.join(projectDir, `${this.memToday()}.md`),
|
|
174
271
|
reflectDir: path.join(projectDir, 'reflections'),
|
|
175
272
|
greetDir: path.join(projectDir, 'greetings'),
|
|
176
|
-
greetPath: path.join(projectDir, 'greetings', `${
|
|
273
|
+
greetPath: path.join(projectDir, 'greetings', `${this.memToday()}.json`),
|
|
274
|
+
greetPathLegacy: path.join(projectDir, 'greetings', `${this.memToday()}.md`),
|
|
177
275
|
}
|
|
178
276
|
}
|
|
179
277
|
|
|
@@ -230,7 +328,7 @@ class MemoryEngine {
|
|
|
230
328
|
const logs = await this.listDailyLogs(projectDir, 30)
|
|
231
329
|
const reflections = await this.listReflections(reflectDir, 30)
|
|
232
330
|
const done = new Set(reflections.map((r) => r.date))
|
|
233
|
-
const today =
|
|
331
|
+
const today = this.memToday()
|
|
234
332
|
for (const log of logs) {
|
|
235
333
|
if (log.date >= today) continue
|
|
236
334
|
if (done.has(log.date)) continue
|
|
@@ -256,6 +354,8 @@ class MemoryEngine {
|
|
|
256
354
|
try {
|
|
257
355
|
if (!this.configLoaded) await this.loadConfig()
|
|
258
356
|
const p = await this.resolvePaths(agent)
|
|
357
|
+
// 旧版分散记忆({ws}/.dsh-memory) → 集中式根目录 自动迁移
|
|
358
|
+
await this.migrateLegacy(p.ws, p.projectDir)
|
|
259
359
|
this.state.ws = p.ws
|
|
260
360
|
this.state.userDir = p.userDir
|
|
261
361
|
this.state.projectDir = p.projectDir
|
|
@@ -281,19 +381,160 @@ class MemoryEngine {
|
|
|
281
381
|
const pending = await this.detectPendingReflection(p.projectDir, p.reflectDir)
|
|
282
382
|
if (pending) this.state.pendingReflection = pending
|
|
283
383
|
}
|
|
284
|
-
// 今日拟人化问候(
|
|
285
|
-
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))
|
|
286
386
|
// 日历/日程(用户级,跨工作区与重装保留)
|
|
287
387
|
this.state.calendarPath = p.calendarPath
|
|
288
388
|
this.state.calendarText = await this.readTextSafe(p.calendarPath)
|
|
289
389
|
// 外部记忆探测(后台,结果进缓存)
|
|
290
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 = [] }
|
|
291
413
|
this.state.loadedAt = Date.now()
|
|
292
414
|
} catch (e) {
|
|
293
415
|
console.error('[dsh-auto-memory] refresh failed', e)
|
|
294
416
|
}
|
|
295
417
|
}
|
|
296
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
|
+
|
|
297
538
|
// ---------- 注入渲染(同步,基于缓存) ----------
|
|
298
539
|
renderMemory(context) {
|
|
299
540
|
const s = this.state
|
|
@@ -301,7 +542,11 @@ class MemoryEngine {
|
|
|
301
542
|
const budget = Math.max(Number(cfg.injectBudgetChars) || 2400, 400)
|
|
302
543
|
const lines = []
|
|
303
544
|
lines.push('<memory_system>')
|
|
304
|
-
lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' +
|
|
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
|
+
}
|
|
305
550
|
let used = 0
|
|
306
551
|
const part = (title, text, max) => {
|
|
307
552
|
if (!text) return
|
|
@@ -341,15 +586,25 @@ class MemoryEngine {
|
|
|
341
586
|
lines.push('\n[日历与日程(未完成)]\n' + calLines + '\n主动关注这些安排:对话中若提及相关时间点,主动用 calendar_add 补充新事项、calendar_done 标记完成;回复正文中向用户转述日历变更。')
|
|
342
587
|
}
|
|
343
588
|
}
|
|
589
|
+
// 暂离回来提示:距上次活动>1小时,要求 agent 在回复开头写欢迎语并提示打开记忆窗口
|
|
590
|
+
if (this._lastActiveAt && Date.now() - this._lastActiveAt > 3600000) {
|
|
591
|
+
lines.push('\n[欢迎回来]')
|
|
592
|
+
lines.push('用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。')
|
|
593
|
+
}
|
|
594
|
+
lines.push('\n[铭文 · 每轮提醒 ' + nowHms() + ']')
|
|
595
|
+
lines.push('思维链=本轮推理(用完即焚);铭文=落盘的记忆文件(跨会话永久)。你的记忆更新必须落在铭文层——显式调用工具写盘,不能只"想过"。')
|
|
596
|
+
lines.push('本提醒由框架在每一轮对话开始时重新注入(时间戳即刷新证明,衰减期只有一轮):读写只走工具、路径写死、每轮结束框架自动评估沉淀兜底——记忆无法被绕过,也不依赖自觉。')
|
|
344
597
|
lines.push('\n[记忆写入纪律 — 必须遵守]')
|
|
345
|
-
lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(CodeBuddy/Claude Code/Codex
|
|
598
|
+
lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(AI 助手/CodeBuddy/Claude Code/Codex 会话),不要凭空猜测**。')
|
|
346
599
|
lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆;检索时在正文中说明"我先查一下之前的记录"。')
|
|
347
600
|
lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
|
|
348
601
|
lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
|
|
349
602
|
lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
|
|
350
|
-
lines.push('-
|
|
603
|
+
lines.push('- 每日写入预算(所有会话共享一天额度,跨天自动重置):用户级 ≤4000 字/天、项目笔记 ≤3000 字/天;超限不拒绝——框架自动把"今天之前"的旧内容交给 AI 浓缩成要点腾出空间(10 分钟内只压缩一次),AI 不可用时归档最早段落,信息不丢;每日日志无预算。')
|
|
604
|
+
lines.push('- **记忆操作必须在正文可见(摘要链)**:调用 memory_log/note/user/reflect 更新记忆后,必须把结果写进本轮回复的正文文本(用户直接看到的那段文字,不是工具调用区),并在**回复末尾**用加粗或换行使其醒目(如"**已更新今日日志**\n新增:修复了XXX");调用 memory_recall/memory_external 检索时,在正文开头写明"我查了记忆,发现..."。工具返回值只是辅助,正文转述是强制要求。')
|
|
351
605
|
lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
|
|
352
|
-
lines.push('- 定期调用 memory_maintain
|
|
606
|
+
lines.push('- 定期调用 memory_maintain 做 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
|
|
607
|
+
lines.push('- 自动沉淀:每轮对话结束,插件会自动评估本轮内容,把有记录价值的写进今日日志([自动沉淀] 标记),有长期价值的升格到项目笔记/用户级记忆,寒暄轮自动跳过。你仍须按上方纪律转述自己的显式记忆操作;也可调用 memory_consolidate 让 AI 读日志发散提炼长期要点。')
|
|
353
608
|
lines.push('- 记忆仅作补充,不替代正常回复与交付物。')
|
|
354
609
|
lines.push('</memory_system>')
|
|
355
610
|
return lines.join('\n')
|
|
@@ -381,6 +636,14 @@ class MemoryEngine {
|
|
|
381
636
|
greetingData() {
|
|
382
637
|
const hour = new Date().getHours()
|
|
383
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
|
+
}
|
|
384
647
|
// 昨天 = 最近一条日志(今天之前的);今天有条目也算最近
|
|
385
648
|
const recent = this.state.recentLogs[0] || null
|
|
386
649
|
const entries = recent ? recent.text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- ')).map((l) => {
|
|
@@ -389,9 +652,9 @@ class MemoryEngine {
|
|
|
389
652
|
}) : []
|
|
390
653
|
return {
|
|
391
654
|
period,
|
|
392
|
-
date:
|
|
393
|
-
hasGreeting: !!
|
|
394
|
-
greeting:
|
|
655
|
+
date: this.memToday(),
|
|
656
|
+
hasGreeting: !!greetText,
|
|
657
|
+
greeting: greetText,
|
|
395
658
|
yesterdayDate: recent ? recent.date : '',
|
|
396
659
|
entries,
|
|
397
660
|
hasPendingReflection: !!this.state.pendingReflection,
|
|
@@ -420,7 +683,7 @@ class MemoryEngine {
|
|
|
420
683
|
const p = await this.resolvePaths(agent)
|
|
421
684
|
const out = []
|
|
422
685
|
const hits = []
|
|
423
|
-
const scanFile = async (label, filePath, maxMatches = 3) => {
|
|
686
|
+
const scanFile = async (label, filePath, maxMatches = 3, target = hits) => {
|
|
424
687
|
const text = await this.readTextSafe(filePath)
|
|
425
688
|
if (!text) return
|
|
426
689
|
const matched = []
|
|
@@ -430,7 +693,7 @@ class MemoryEngine {
|
|
|
430
693
|
if (matched.length >= maxMatches) break
|
|
431
694
|
}
|
|
432
695
|
}
|
|
433
|
-
if (matched.length)
|
|
696
|
+
if (matched.length) target.push({ where: label, matches: matched })
|
|
434
697
|
}
|
|
435
698
|
// 读取顺序:progress(日志/反思)先行,再读 memory(用户级/项目笔记)
|
|
436
699
|
const logs = await this.listDailyLogs(p.projectDir, 40)
|
|
@@ -449,11 +712,33 @@ class MemoryEngine {
|
|
|
449
712
|
out.push('== 本地记忆文件命中 ==')
|
|
450
713
|
for (const h of hits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
|
|
451
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
|
+
}
|
|
452
737
|
// 外部记忆(其他 AI 工具遗产)检索
|
|
453
738
|
try {
|
|
454
739
|
const extHits = await this.external.search(query, Math.max(limit - hits.length, 2))
|
|
455
740
|
if (extHits.length) {
|
|
456
|
-
out.push('== 外部记忆命中(CodeBuddy/Claude/Codex
|
|
741
|
+
out.push('== 外部记忆命中(AI 助手/CodeBuddy/Claude/Codex/项目约定) ==')
|
|
457
742
|
for (const h of extHits) out.push('· ' + h.source + '(' + h.tool + '):\n' + h.lines.map((m) => ' - ' + m).join('\n'))
|
|
458
743
|
}
|
|
459
744
|
} catch (e) {}
|
|
@@ -478,6 +763,299 @@ class MemoryEngine {
|
|
|
478
763
|
return '[记忆检索] "' + q + '":\n' + out.join('\n')
|
|
479
764
|
}
|
|
480
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
|
+
|
|
481
1059
|
// ---------- 反思 ----------
|
|
482
1060
|
async saveReflection(date, text, agent) {
|
|
483
1061
|
if (!DATE_RE.test(date)) return 'memory_reflect: date 必须是 YYYY-MM-DD。'
|
|
@@ -587,6 +1165,373 @@ class MemoryEngine {
|
|
|
587
1165
|
return { entries, groups, todayDate: todayStr() }
|
|
588
1166
|
}
|
|
589
1167
|
|
|
1168
|
+
/** 生活化总结:调用 DSH 的 AI(subagent)发散地总结某时段的工作(完成/收尾/烦恼/暂停的事)。 */
|
|
1169
|
+
async summarizePeriod(period, agent, force) {
|
|
1170
|
+
const ps = this.periodSummary()
|
|
1171
|
+
const groups = ps.groups || {}
|
|
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] || [])
|
|
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
|
+
}
|
|
1189
|
+
const prompt = [
|
|
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>',
|
|
1201
|
+
'',
|
|
1202
|
+
'用户时段工作记录(' + period + '):',
|
|
1203
|
+
items.map((x) => '- ' + x).join('\n'),
|
|
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
|
+
// 已有该时段缓存 → 直接返回
|
|
1239
|
+
try {
|
|
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()
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
console.error('[dsh-auto-memory] ' + label + ' failed', e && e.message ? e.message : e)
|
|
1295
|
+
return ''
|
|
1296
|
+
} finally {
|
|
1297
|
+
clearTimeout(timer)
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
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
|
+
|
|
590
1535
|
/** 一键反思:自动取"有日志但无反思"的最早日期,按日志条目生成反思草稿并落盘。 */
|
|
591
1536
|
async reflectAuto(agent) {
|
|
592
1537
|
const p = await this.resolvePaths(agent)
|
|
@@ -609,6 +1554,7 @@ class MemoryEngine {
|
|
|
609
1554
|
}
|
|
610
1555
|
|
|
611
1556
|
// ---------- 维护 ----------
|
|
1557
|
+
/** 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档到 archive/,活跃日志移除。 */
|
|
612
1558
|
async maintain(days = 30, agent) {
|
|
613
1559
|
const p = await this.resolvePaths(agent)
|
|
614
1560
|
const cutoff = new Date()
|
|
@@ -619,18 +1565,63 @@ class MemoryEngine {
|
|
|
619
1565
|
if (!m) return false
|
|
620
1566
|
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) < cutoff
|
|
621
1567
|
})
|
|
622
|
-
if (!oldLogs.length) return '没有超过 ' + days + '
|
|
623
|
-
|
|
624
|
-
let
|
|
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 = []
|
|
625
1602
|
for (const log of oldLogs) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
630
1619
|
}
|
|
1620
|
+
const body = await this.appendText(p.notesPath, archive)
|
|
1621
|
+
this.state.notesText = body
|
|
1622
|
+
noteMsg = '\nAI 蒸馏不可用,原文已按老方式归档到 ' + p.notesPath
|
|
631
1623
|
}
|
|
632
|
-
|
|
633
|
-
this.state.notesText = notesBody
|
|
1624
|
+
// 4) 活跃目录移除旧日志(原文已在 archive/ 保底)
|
|
634
1625
|
const deleted = []
|
|
635
1626
|
const kept = []
|
|
636
1627
|
for (const log of oldLogs) {
|
|
@@ -640,8 +1631,9 @@ class MemoryEngine {
|
|
|
640
1631
|
} catch (e) { kept.push(log.name) }
|
|
641
1632
|
}
|
|
642
1633
|
this.state.loadedAt = Date.now()
|
|
643
|
-
return '
|
|
644
|
-
|
|
1634
|
+
return '30 天蒸馏完成:' + (distillText ? ' AI 提炼 + 原文归档' : ' 原文归档') + ' ' + oldLogs.length + ' 个旧日志(' + deleted.join(', ') + ')' +
|
|
1635
|
+
'\n原文保底: ' + archiveDir + ' (' + archived.length + ' 个文件)' +
|
|
1636
|
+
noteMsg +
|
|
645
1637
|
(kept.length ? '\n未删除(可手动清理): ' + kept.join(', ') : '')
|
|
646
1638
|
}
|
|
647
1639
|
|
|
@@ -667,6 +1659,7 @@ class MemoryEngine {
|
|
|
667
1659
|
todayEntries,
|
|
668
1660
|
latestReflectionDate: this.state.latestReflectionDate,
|
|
669
1661
|
pendingReflection: this.state.pendingReflection ? this.state.pendingReflection.date : undefined,
|
|
1662
|
+
autoStats: this.autoStats,
|
|
670
1663
|
greeting: this.greetingData(),
|
|
671
1664
|
calendar: this.parseCalendar(this.state.calendarText),
|
|
672
1665
|
calendarPath: this.state.calendarPath,
|
|
@@ -749,12 +1742,12 @@ function isUnderMemoryTree(engine, target) {
|
|
|
749
1742
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
750
1743
|
// 外部记忆接入(其他 AI 工具的记忆继承)
|
|
751
1744
|
//
|
|
752
|
-
// 目标:让 DSH 继承用户在 CodeBuddy / Claude Code / Codex /
|
|
753
|
-
//
|
|
1745
|
+
// 目标:让 DSH 继承用户在 AI 助手 / CodeBuddy / Claude Code / Codex /
|
|
1746
|
+
// Cursor 等工具中积累的记忆,持续拟合用户画像。
|
|
754
1747
|
//
|
|
755
1748
|
// 源分三类:
|
|
756
1749
|
// - markdown 记忆(用户级/画像/项目约定):内容小,直接读入缓存,注入/检索/接入
|
|
757
|
-
// - 会话日志(jsonl
|
|
1750
|
+
// - 会话日志(jsonl,AI 助手 projects / Claude projects / Codex sessions):
|
|
758
1751
|
// 只列索引,检索时按需扫描(行数/文件数上限),绝不整库注入
|
|
759
1752
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
760
1753
|
class ExternalMemory {
|
|
@@ -951,7 +1944,7 @@ class ExternalMemory {
|
|
|
951
1944
|
const src = srcs.find((s) => s.id === sourceId)
|
|
952
1945
|
if (!src) return '外部源不存在: ' + sourceId
|
|
953
1946
|
if (src.kind === 'sessions') return '会话类源不支持整体接入,请用 memory_recall 按需检索(' + src.files.length + ' 个会话文件)。'
|
|
954
|
-
const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' +
|
|
1947
|
+
const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + this.memToday()
|
|
955
1948
|
if (target === 'user') {
|
|
956
1949
|
const p = await engine.resolvePaths(agent)
|
|
957
1950
|
const body = await engine.appendText(p.userFile, '\n' + stamp + '\n' + src.content.slice(0, 120000))
|
|
@@ -977,6 +1970,33 @@ class ExternalMemory {
|
|
|
977
1970
|
}
|
|
978
1971
|
}
|
|
979
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
|
+
|
|
980
2000
|
/** 递归收集目录下匹配正则的文件(上限 n)。 */
|
|
981
2001
|
async function globOne(dir, re, limit) {
|
|
982
2002
|
const out = []
|
|
@@ -1027,12 +2047,42 @@ export function apply(ctx, config) {
|
|
|
1027
2047
|
const engine = new MemoryEngine()
|
|
1028
2048
|
const sessionQuery = ctx.get('sessionQuery')
|
|
1029
2049
|
engine._sessionQuery = sessionQuery
|
|
2050
|
+
engine._subagents = ctx.get('subagents') || undefined
|
|
1030
2051
|
|
|
1031
2052
|
// 生命周期刷新
|
|
1032
2053
|
const refreshAll = (agent) => { void engine.refresh(agent) }
|
|
1033
2054
|
refreshAll()
|
|
1034
|
-
ctx.on('agent/session-start', (payload) =>
|
|
1035
|
-
|
|
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
|
+
})
|
|
2060
|
+
ctx.on('agent/turn-stopping', (payload) => {
|
|
2061
|
+
refreshAll(payload && payload.agent)
|
|
2062
|
+
// 记录最后活动时间(判断暂离回来用)
|
|
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()
|
|
2085
|
+
})
|
|
1036
2086
|
|
|
1037
2087
|
// ---------- 系统提示词注入 ----------
|
|
1038
2088
|
const disposeSection = ctx.systemPrompt.section({
|
|
@@ -1042,7 +2092,7 @@ export function apply(ctx, config) {
|
|
|
1042
2092
|
try {
|
|
1043
2093
|
const agent = context && context.agent
|
|
1044
2094
|
if (!agent) return ''
|
|
1045
|
-
if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt >
|
|
2095
|
+
if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 15000) {
|
|
1046
2096
|
void engine.refresh(agent)
|
|
1047
2097
|
}
|
|
1048
2098
|
return engine.renderMemory(context) + engine.renderReflectionRequest()
|
|
@@ -1056,57 +2106,61 @@ export function apply(ctx, config) {
|
|
|
1056
2106
|
note: { type: 'string', required: true, description: '简短条目:一句话概括做了什么、结果如何。' },
|
|
1057
2107
|
date: { type: 'string', description: '日志日期 YYYY-MM-DD,缺省今天。' },
|
|
1058
2108
|
}, async (args, exec) => {
|
|
1059
|
-
const date = DATE_RE.test(args.date || '') ? args.date :
|
|
2109
|
+
const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
|
|
1060
2110
|
const p = await engine.resolvePaths(exec.agent)
|
|
1061
2111
|
const logPath = path.join(p.projectDir, date + '.md')
|
|
1062
2112
|
const entry = '- ' + nowHm() + ' ' + String(args.note).trim()
|
|
1063
2113
|
const body = await engine.appendText(logPath, entry)
|
|
1064
|
-
if (date ===
|
|
2114
|
+
if (date === engine.memToday()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
|
|
1065
2115
|
return '已更新记忆文档: ' + logPath + '\n' + entry
|
|
1066
2116
|
}),
|
|
1067
2117
|
|
|
1068
|
-
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 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:更新了项目笔记、加入什么要点**。', {
|
|
1069
2119
|
content: { type: 'string', required: true, description: '笔记内容。' },
|
|
1070
2120
|
action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
|
|
1071
2121
|
}, async (args, exec) => {
|
|
1072
2122
|
const p = await engine.resolvePaths(exec.agent)
|
|
1073
2123
|
const content = String(args.content || '').trim()
|
|
1074
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 整理。'
|
|
1075
2127
|
let body
|
|
1076
2128
|
if (args.action === 'replace') {
|
|
1077
2129
|
body = content
|
|
1078
2130
|
await engine.writeFull(p.notesPath, body)
|
|
1079
2131
|
} else {
|
|
1080
|
-
body = await engine.appendText(p.notesPath, '\n## ' +
|
|
2132
|
+
body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + content)
|
|
1081
2133
|
}
|
|
1082
2134
|
engine.state.notesText = body; engine.state.loadedAt = Date.now()
|
|
1083
|
-
return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + content
|
|
2135
|
+
return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
|
|
1084
2136
|
}),
|
|
1085
2137
|
|
|
1086
|
-
defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace
|
|
2138
|
+
defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。每日预算 4000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
|
|
1087
2139
|
content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
|
|
1088
2140
|
action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
|
|
1089
2141
|
}, async (args, exec) => {
|
|
1090
2142
|
const p = await engine.resolvePaths(exec.agent)
|
|
1091
2143
|
const content = String(args.content || '').trim()
|
|
1092
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 整理。'
|
|
1093
2147
|
let body
|
|
1094
2148
|
if (args.action === 'replace') {
|
|
1095
2149
|
body = content
|
|
1096
2150
|
await engine.writeFull(p.userFile, body)
|
|
1097
2151
|
} else {
|
|
1098
|
-
body = await engine.appendText(p.userFile, '\n## ' +
|
|
2152
|
+
body = await engine.appendText(p.userFile, '\n## ' + engine.memToday() + '\n' + content)
|
|
1099
2153
|
}
|
|
1100
2154
|
engine.state.userText = body; engine.state.loadedAt = Date.now()
|
|
1101
|
-
return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content
|
|
2155
|
+
return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
|
|
1102
2156
|
}),
|
|
1103
2157
|
|
|
1104
|
-
defineTool('memory_recall', '检索记忆:本地记忆文件(
|
|
2158
|
+
defineTool('memory_recall', '检索记忆:本地记忆文件(当前工作区 + 其他所有工作区的每日日志、项目笔记、用户级记忆、反思)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。开发/排查中不懂的、用户提到过去的做法/讨论/决定而当前上下文没有时调用——跨工作区的记忆也能检索到(结果标注来源工作区)。查询必须自包含。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
|
|
1105
2159
|
query: { type: 'string', required: true, description: '检索关键词或自包含描述。' },
|
|
1106
2160
|
limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
|
|
1107
2161
|
}, async (args, exec) => engine.recall(args.query, args.limit, exec.agent)),
|
|
1108
2162
|
|
|
1109
|
-
defineTool('memory_maintain', '
|
|
2163
|
+
defineTool('memory_maintain', '维护记忆(30 天蒸馏):把 days(缺省30)天前的 .dsh-memory/ 每日日志交给 AI 蒸馏提炼出有长期价值的要点写入项目 MEMORY.md,原文保底归档到 .dsh-memory/archive/ 后从活跃日志移除。AI 不可用时降级为原样归档,不丢信息。', {
|
|
1110
2164
|
days: { type: 'integer', description: '归档阈值天数,缺省 30。' },
|
|
1111
2165
|
}, async (args, exec) => engine.maintain(args.days, exec.agent)),
|
|
1112
2166
|
|
|
@@ -1127,14 +2181,14 @@ export function apply(ctx, config) {
|
|
|
1127
2181
|
text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
|
|
1128
2182
|
}, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
|
|
1129
2183
|
|
|
1130
|
-
defineTool('memory_external', '查看/接入其他 AI 工具(CodeBuddy/Claude Code/Codex
|
|
2184
|
+
defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 把某源内容整体接入本地记忆(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,自动标注来源)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
|
|
1131
2185
|
action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
|
|
1132
2186
|
source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
|
|
1133
2187
|
target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
|
|
1134
2188
|
}, async (args, exec) => {
|
|
1135
2189
|
if (args.action === 'list') {
|
|
1136
2190
|
const list = await engine.external.summarize()
|
|
1137
|
-
if (!list.length) return '未检测到其他 AI 工具的记忆文件(可检查 ~/.codebuddy、~/.claude、~/.codex
|
|
2191
|
+
if (!list.length) return '未检测到其他 AI 工具的记忆文件(可检查 ~/.workbuddy、~/.codebuddy、~/.claude、~/.codex 是否存在)。'
|
|
1138
2192
|
const lines = []
|
|
1139
2193
|
lines.push('检测到 ' + list.length + ' 个外部记忆源:')
|
|
1140
2194
|
for (const s of list) {
|
|
@@ -1181,6 +2235,10 @@ export function apply(ctx, config) {
|
|
|
1181
2235
|
time: { type: 'string', required: true, description: '时间 HH:MM 或 --:--。' },
|
|
1182
2236
|
title: { type: 'string', required: true, description: '事项标题。' },
|
|
1183
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))),
|
|
1184
2242
|
]
|
|
1185
2243
|
|
|
1186
2244
|
// ---------- 路由 ----------
|
|
@@ -1191,7 +2249,13 @@ export function apply(ctx, config) {
|
|
|
1191
2249
|
handler: async (req, res) => {
|
|
1192
2250
|
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1193
2251
|
if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1194
|
-
try {
|
|
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) }) }
|
|
1195
2259
|
},
|
|
1196
2260
|
},
|
|
1197
2261
|
{
|
|
@@ -1246,6 +2310,86 @@ export function apply(ctx, config) {
|
|
|
1246
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) }) }
|
|
1247
2311
|
},
|
|
1248
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
|
+
},
|
|
1249
2393
|
{
|
|
1250
2394
|
kind: 'exact',
|
|
1251
2395
|
path: API.config,
|
|
@@ -1263,7 +2407,8 @@ export function apply(ctx, config) {
|
|
|
1263
2407
|
const allowed = Object.keys(DEFAULT_CONFIG)
|
|
1264
2408
|
const patch = {}
|
|
1265
2409
|
for (const key of allowed) if (body[key] !== undefined) patch[key] = body[key]
|
|
1266
|
-
|
|
2410
|
+
const saved = await engine.saveConfig(patch)
|
|
2411
|
+
writeJson(res, 200, { config: saved.config, migrated: saved.migrated || '' })
|
|
1267
2412
|
return
|
|
1268
2413
|
}
|
|
1269
2414
|
writeJson(res, 405, { error: 'method not allowed' })
|
|
@@ -1280,7 +2425,7 @@ export function apply(ctx, config) {
|
|
|
1280
2425
|
if (!body || typeof body.content !== 'string' || !body.content.trim()) return writeJson(res, 400, { error: 'invalid body' })
|
|
1281
2426
|
try {
|
|
1282
2427
|
const p = await engine.resolvePaths(undefined)
|
|
1283
|
-
const text = '\n## ' +
|
|
2428
|
+
const text = '\n## ' + engine.memToday() + '\n' + body.content.trim()
|
|
1284
2429
|
const updated = await engine.appendText(p.notesPath, text)
|
|
1285
2430
|
engine.state.notesText = updated
|
|
1286
2431
|
engine.state.loadedAt = Date.now()
|
|
@@ -1355,14 +2500,77 @@ export function apply(ctx, config) {
|
|
|
1355
2500
|
writeJson(res, 405, { error: 'method not allowed' })
|
|
1356
2501
|
},
|
|
1357
2502
|
},
|
|
2503
|
+
{
|
|
2504
|
+
kind: 'exact',
|
|
2505
|
+
path: API.summarize,
|
|
2506
|
+
handler: async (req, res) => {
|
|
2507
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
2508
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
2509
|
+
const body = await readJsonBody(req)
|
|
2510
|
+
if (!body || typeof body.period !== 'string') return writeJson(res, 400, { error: 'invalid body' })
|
|
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) }) }
|
|
2530
|
+
},
|
|
2531
|
+
},
|
|
1358
2532
|
]
|
|
1359
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
|
+
|
|
1360
2566
|
// ---------- 注册与清理 ----------
|
|
1361
2567
|
const disposers = []
|
|
1362
2568
|
disposers.push(disposeSection)
|
|
1363
2569
|
for (const tool of tools) disposers.push(ctx.tools.register(tool))
|
|
1364
2570
|
for (const route of routes) disposers.push(ctx.webServer.register(route))
|
|
1365
2571
|
ctx.effect(() => () => {
|
|
2572
|
+
clearInterval(retryTimer)
|
|
2573
|
+
clearInterval(heartbeatTimer)
|
|
1366
2574
|
for (const dispose of disposers) { try { dispose() } catch (e) {} }
|
|
1367
2575
|
}, 'dsh-auto-memory: surfaces')
|
|
1368
2576
|
|