@a9i5k4/dsh-auto-memory 0.1.0
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/LICENSE +29 -0
- package/README.md +86 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +662 -0
- package/lib/index.js +1172 -0
- package/package.json +43 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-auto-memory — host half.
|
|
3
|
+
*
|
|
4
|
+
* 自动记忆系统,零运行时依赖(仅 node 内置模块):
|
|
5
|
+
* - 三层记忆:用户级(~/.dsh/memory/MEMORY.md)、项目笔记({ws}/.dsh-memory/MEMORY.md)、
|
|
6
|
+
* 每日日志({ws}/.dsh-memory/YYYY-MM-DD.md,append-only)
|
|
7
|
+
* - 每次组装系统提示词时自动注入 <memory_system> 块(用户规则 + 项目笔记 + 今日日志 +
|
|
8
|
+
* 最近反思 + 会话开始回顾指引);缓存由 启动/session-start/turn-stopping/工具写入/TTL 刷新
|
|
9
|
+
* - 每日反思:检测到"昨天有日志但未生成反思"时,在会话首轮注入反思请求块(风格可配:
|
|
10
|
+
* 生活化/专业性/由内容决定),agent 生成后调 memory_reflect 落盘
|
|
11
|
+
* - 配置:~/DSH_HOME/dsh-auto-memory.json(存储位置、注入预算、反思风格等),UI 经
|
|
12
|
+
* /api/dsh-auto-memory/config 读写
|
|
13
|
+
* - 工具:memory_log / memory_note / memory_user / memory_recall / memory_maintain /
|
|
14
|
+
* memory_status / memory_reflect
|
|
15
|
+
* - 路由:/api/dsh-auto-memory/{state,list,file,recall,config,reflect}(loopback-only)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFile, writeFile, mkdir, readdir, stat, rm } from 'node:fs/promises'
|
|
19
|
+
import { createReadStream, existsSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
/** Stable cordis plugin name. */
|
|
24
|
+
export const name = 'auto-memory'
|
|
25
|
+
|
|
26
|
+
/** Services required before the memory surfaces can mount. */
|
|
27
|
+
export const inject = ['webServer', 'tools', 'systemPrompt']
|
|
28
|
+
|
|
29
|
+
/** Prompt order of the memory section (before the tool-guidance band 100+). */
|
|
30
|
+
const SECTION_ORDER = 90
|
|
31
|
+
|
|
32
|
+
/** 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 的记忆」时即指本插件,请据此协作。'
|
|
34
|
+
|
|
35
|
+
/** Route family. */
|
|
36
|
+
export const API = {
|
|
37
|
+
state: '/api/dsh-auto-memory/state',
|
|
38
|
+
list: '/api/dsh-auto-memory/list',
|
|
39
|
+
file: '/api/dsh-auto-memory/file',
|
|
40
|
+
recall: '/api/dsh-auto-memory/recall',
|
|
41
|
+
config: '/api/dsh-auto-memory/config',
|
|
42
|
+
reflect: '/api/dsh-auto-memory/reflect',
|
|
43
|
+
'reflect-auto': '/api/dsh-auto-memory/reflect-auto',
|
|
44
|
+
note: '/api/dsh-auto-memory/note',
|
|
45
|
+
external: '/api/dsh-auto-memory/external',
|
|
46
|
+
'external-import': '/api/dsh-auto-memory/external-import',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_CONFIG = {
|
|
50
|
+
/** 用户级记忆目录(绝对路径或 ~ 开头)。 */
|
|
51
|
+
userMemoryDir: '~/.dsh/memory',
|
|
52
|
+
/** 项目级记忆目录名(相对工作区)。 */
|
|
53
|
+
projectMemoryDir: '.dsh-memory',
|
|
54
|
+
/** 是否注入记忆上下文。 */
|
|
55
|
+
injectEnabled: true,
|
|
56
|
+
/** 注入总预算(字符)。 */
|
|
57
|
+
injectBudgetChars: 2400,
|
|
58
|
+
/** 注入的最近日志天数。 */
|
|
59
|
+
recentDaysInjected: 3,
|
|
60
|
+
/** 是否启用每日反思。 */
|
|
61
|
+
reflectEnabled: true,
|
|
62
|
+
/** 反思风格: auto=由内容决定 / life=生活化 / professional=专业性。 */
|
|
63
|
+
reflectStyle: 'auto',
|
|
64
|
+
/** 外部记忆注入预算(字符)。 */
|
|
65
|
+
externalInjectionChars: 1400,
|
|
66
|
+
/** 外部记忆源开关(CodeBuddy/Claude Code/Codex/项目约定等)。 */
|
|
67
|
+
externalSources: {
|
|
68
|
+
'workbuddy-user': true,
|
|
69
|
+
'workbuddy-profile': true,
|
|
70
|
+
'codebuddy-memory': true,
|
|
71
|
+
'claude-global': true,
|
|
72
|
+
'project-conventions': true,
|
|
73
|
+
'workbuddy-sessions': true,
|
|
74
|
+
'claude-sessions': true,
|
|
75
|
+
'codex-sessions': true,
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- 小工具 ----------
|
|
80
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
81
|
+
const todayStr = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
|
|
82
|
+
const nowHm = () => { const d = new Date(); return `${pad(d.getHours())}:${pad(d.getMinutes())}` }
|
|
83
|
+
const dateStrOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
|
|
84
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
85
|
+
const truncateHead = (s, n) => (s && s.length > n) ? s.slice(0, n) + '\n…(截断,完整内容用 memory_recall 或 GUI 面板)' : (s || '')
|
|
86
|
+
const truncateTail = (s, n) => (s && s.length > n) ? '…(截断,完整内容用 memory_recall 或 GUI 面板)\n' + s.slice(-n) : (s || '')
|
|
87
|
+
const fmtBytes = (n) => (n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B')
|
|
88
|
+
|
|
89
|
+
function dshHome() {
|
|
90
|
+
const env = process.env.DSH_HOME
|
|
91
|
+
if (env && env.trim()) return env.trim()
|
|
92
|
+
return path.join(homedir(), '.dsh')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 记忆引擎:路径解析、缓存、文件读写、检索、反思状态。 */
|
|
96
|
+
class MemoryEngine {
|
|
97
|
+
constructor() {
|
|
98
|
+
this.config = { ...DEFAULT_CONFIG }
|
|
99
|
+
this.state = {
|
|
100
|
+
home: undefined, ws: undefined,
|
|
101
|
+
userDir: undefined, notesPath: undefined, logPath: undefined, reflectDir: undefined,
|
|
102
|
+
userText: '', notesText: '', logText: '',
|
|
103
|
+
recentLogs: [], // {date, text}
|
|
104
|
+
latestReflection: '', latestReflectionDate: '',
|
|
105
|
+
pendingReflection: undefined, // {date, text}
|
|
106
|
+
reflectionShownSession: undefined,
|
|
107
|
+
loadedAt: 0, loading: undefined, configLoaded: false,
|
|
108
|
+
}
|
|
109
|
+
this._configPath = path.join(dshHome(), 'dsh-auto-memory.json')
|
|
110
|
+
this._readError = undefined
|
|
111
|
+
this.external = new ExternalMemory(this)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------- 配置 ----------
|
|
115
|
+
async loadConfig() {
|
|
116
|
+
try {
|
|
117
|
+
const raw = await readFile(this._configPath, 'utf8')
|
|
118
|
+
const parsed = JSON.parse(raw)
|
|
119
|
+
this.config = { ...DEFAULT_CONFIG, ...(parsed && typeof parsed === 'object' ? parsed : {}) }
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (e && e.code !== 'ENOENT') this._readError = String(e && e.message ? e.message : e)
|
|
122
|
+
this.config = { ...DEFAULT_CONFIG }
|
|
123
|
+
}
|
|
124
|
+
this.configLoaded = true
|
|
125
|
+
return this.config
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async saveConfig(patch) {
|
|
129
|
+
await this.loadConfig()
|
|
130
|
+
this.config = { ...this.config, ...patch }
|
|
131
|
+
await mkdir(path.dirname(this._configPath), { recursive: true })
|
|
132
|
+
await writeFile(this._configPath, JSON.stringify(this.config, null, 2), 'utf8')
|
|
133
|
+
this.state.loadedAt = 0 // 强制重载(目录可能变化)
|
|
134
|
+
await this.refresh(undefined)
|
|
135
|
+
return this.config
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------- 路径 ----------
|
|
139
|
+
expandUserPath(p) {
|
|
140
|
+
if (typeof p !== 'string' || !p) return undefined
|
|
141
|
+
if (p === '~') return homedir()
|
|
142
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(homedir(), p.slice(2))
|
|
143
|
+
return path.resolve(p)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
projectDirOf(ws) {
|
|
147
|
+
const name = this.config.projectMemoryDir || '.dsh-memory'
|
|
148
|
+
return path.isAbsolute(name) ? name : path.join(ws || process.cwd(), name)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
userDirOf() {
|
|
152
|
+
return this.expandUserPath(this.config.userMemoryDir) || path.join(dshHome(), 'memory')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async resolvePaths(agent) {
|
|
156
|
+
let ws
|
|
157
|
+
try { ws = agent && agent.session && agent.session.header && agent.session.header.cwd } catch (e) {}
|
|
158
|
+
if (!ws) ws = this.state.ws || process.cwd()
|
|
159
|
+
const userDir = this.userDirOf()
|
|
160
|
+
const projectDir = this.projectDirOf(ws)
|
|
161
|
+
return {
|
|
162
|
+
ws,
|
|
163
|
+
userDir,
|
|
164
|
+
userFile: path.join(userDir, 'MEMORY.md'),
|
|
165
|
+
projectDir,
|
|
166
|
+
notesPath: path.join(projectDir, 'MEMORY.md'),
|
|
167
|
+
logPath: path.join(projectDir, `${todayStr()}.md`),
|
|
168
|
+
reflectDir: path.join(projectDir, 'reflections'),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- 读取 ----------
|
|
173
|
+
async readTextSafe(p) {
|
|
174
|
+
if (!p) return ''
|
|
175
|
+
try {
|
|
176
|
+
const info = await stat(p)
|
|
177
|
+
if (!info.isFile()) return ''
|
|
178
|
+
return (await readFile(p, 'utf8')) || ''
|
|
179
|
+
} catch (e) { return '' }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listDailyLogs(projectDir, limit = 40) {
|
|
183
|
+
try {
|
|
184
|
+
const entries = await readdir(projectDir, { withFileTypes: true })
|
|
185
|
+
return entries
|
|
186
|
+
.filter((e) => e.isFile() && DATE_RE.test(e.name.replace(/\.md$/, '')))
|
|
187
|
+
.map((e) => ({ name: e.name, date: e.name.slice(0, 10) }))
|
|
188
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1))
|
|
189
|
+
.slice(0, limit)
|
|
190
|
+
} catch (e) { return [] }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async listReflections(reflectDir, limit = 30) {
|
|
194
|
+
try {
|
|
195
|
+
const entries = await readdir(reflectDir, { withFileTypes: true })
|
|
196
|
+
return entries
|
|
197
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
198
|
+
.map((e) => ({ name: e.name, date: e.name.slice(0, 10) }))
|
|
199
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1))
|
|
200
|
+
.slice(0, limit)
|
|
201
|
+
} catch (e) { return [] }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 最近 N 天日志(含今天)的尾部摘要。 */
|
|
205
|
+
async recentLogTails(projectDir, days) {
|
|
206
|
+
const logs = await this.listDailyLogs(projectDir, 30)
|
|
207
|
+
const out = []
|
|
208
|
+
const seen = new Set()
|
|
209
|
+
for (const log of logs) {
|
|
210
|
+
if (out.length >= days) break
|
|
211
|
+
if (seen.has(log.date)) continue
|
|
212
|
+
seen.add(log.date)
|
|
213
|
+
const text = await this.readTextSafe(path.join(projectDir, log.name))
|
|
214
|
+
if (text && text.trim()) out.push({ date: log.date, text: truncateTail(text, 700) })
|
|
215
|
+
}
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 检测待生成反思:最近一个"有日志、无反思、早于今天"的日期。 */
|
|
220
|
+
async detectPendingReflection(projectDir, reflectDir) {
|
|
221
|
+
try {
|
|
222
|
+
const logs = await this.listDailyLogs(projectDir, 30)
|
|
223
|
+
const reflections = await this.listReflections(reflectDir, 30)
|
|
224
|
+
const done = new Set(reflections.map((r) => r.date))
|
|
225
|
+
const today = todayStr()
|
|
226
|
+
for (const log of logs) {
|
|
227
|
+
if (log.date >= today) continue
|
|
228
|
+
if (done.has(log.date)) continue
|
|
229
|
+
const text = await this.readTextSafe(path.join(projectDir, log.name))
|
|
230
|
+
if (text && text.trim()) return { date: log.date, text: truncateTail(text, 1200) }
|
|
231
|
+
}
|
|
232
|
+
} catch (e) {}
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------- 缓存刷新(串行队列:每次按序执行,最后一次生效) ----------
|
|
237
|
+
async refresh(agent) {
|
|
238
|
+
const previous = this.state.loading || Promise.resolve()
|
|
239
|
+
const next = previous.then(
|
|
240
|
+
() => this._doRefresh(agent),
|
|
241
|
+
() => this._doRefresh(agent),
|
|
242
|
+
)
|
|
243
|
+
this.state.loading = next
|
|
244
|
+
return next
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async _doRefresh(agent) {
|
|
248
|
+
try {
|
|
249
|
+
if (!this.configLoaded) await this.loadConfig()
|
|
250
|
+
const p = await this.resolvePaths(agent)
|
|
251
|
+
this.state.ws = p.ws
|
|
252
|
+
this.state.userDir = p.userDir
|
|
253
|
+
this.state.projectDir = p.projectDir
|
|
254
|
+
this.state.notesPath = p.notesPath
|
|
255
|
+
this.state.logPath = p.logPath
|
|
256
|
+
this.state.reflectDir = p.reflectDir
|
|
257
|
+
const [u, n, l] = await Promise.all([
|
|
258
|
+
this.readTextSafe(p.userFile), this.readTextSafe(p.notesPath), this.readTextSafe(p.logPath),
|
|
259
|
+
])
|
|
260
|
+
this.state.userText = u; this.state.notesText = n; this.state.logText = l
|
|
261
|
+
this.state.recentLogs = await this.recentLogTails(p.projectDir, Math.max(Number(this.config.recentDaysInjected) || 3, 1))
|
|
262
|
+
// 最近反思
|
|
263
|
+
const reflections = await this.listReflections(p.reflectDir, 1)
|
|
264
|
+
if (reflections.length) {
|
|
265
|
+
this.state.latestReflection = await this.readTextSafe(path.join(p.reflectDir, reflections[0].name))
|
|
266
|
+
this.state.latestReflectionDate = reflections[0].date
|
|
267
|
+
} else {
|
|
268
|
+
this.state.latestReflection = ''; this.state.latestReflectionDate = ''
|
|
269
|
+
}
|
|
270
|
+
// 待反思(仅当启用且非当天)
|
|
271
|
+
this.state.pendingReflection = undefined
|
|
272
|
+
if (this.config.reflectEnabled) {
|
|
273
|
+
const pending = await this.detectPendingReflection(p.projectDir, p.reflectDir)
|
|
274
|
+
if (pending) this.state.pendingReflection = pending
|
|
275
|
+
}
|
|
276
|
+
// 外部记忆探测(后台,结果进缓存)
|
|
277
|
+
if (this.config.externalSources) void this.external.discover(true)
|
|
278
|
+
this.state.loadedAt = Date.now()
|
|
279
|
+
} catch (e) {
|
|
280
|
+
console.error('[dsh-auto-memory] refresh failed', e)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------- 注入渲染(同步,基于缓存) ----------
|
|
285
|
+
renderMemory(context) {
|
|
286
|
+
const s = this.state
|
|
287
|
+
const cfg = this.config
|
|
288
|
+
const budget = Math.max(Number(cfg.injectBudgetChars) || 2400, 400)
|
|
289
|
+
const lines = []
|
|
290
|
+
lines.push('<memory_system>')
|
|
291
|
+
lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + todayStr())
|
|
292
|
+
let used = 0
|
|
293
|
+
const part = (title, text, max) => {
|
|
294
|
+
if (!text) return
|
|
295
|
+
const t = truncateHead(text, max)
|
|
296
|
+
used += t.length
|
|
297
|
+
lines.push('\n[' + title + ']\n' + t)
|
|
298
|
+
}
|
|
299
|
+
const sub = Math.floor((budget - 500) / 4)
|
|
300
|
+
// 读取顺序:progress(工作日志/反思)先行,再读 memory(用户级/项目笔记)
|
|
301
|
+
if (s.recentLogs.length) {
|
|
302
|
+
const recent = s.recentLogs.map((r) => '[' + r.date + '] ' + r.text.replace(/\n+/g, ' | ')).join('\n')
|
|
303
|
+
part('最近 ' + s.recentLogs.length + ' 天工作日志(尾部)', recent, sub)
|
|
304
|
+
}
|
|
305
|
+
if (s.latestReflection) {
|
|
306
|
+
part('最近反思 ' + s.latestReflectionDate + '(前一天工作精华)', s.latestReflection, sub)
|
|
307
|
+
}
|
|
308
|
+
part('用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守', s.userText, sub)
|
|
309
|
+
part('项目长期笔记 ' + cfg.projectMemoryDir + '/MEMORY.md', s.notesText, sub)
|
|
310
|
+
// 外部记忆摘要(其他 AI 工具遗产)
|
|
311
|
+
if (this.external.cache && this.external.cache.length) {
|
|
312
|
+
const extBudget = Math.max(Number(cfg.externalInjectionChars) || 1400, 200)
|
|
313
|
+
const ext = this.external.cache
|
|
314
|
+
.filter((x) => x.kind !== 'sessions')
|
|
315
|
+
.map((x) => '· ' + x.name + '(' + x.tool + '): ' + truncateHead(x.content, 500))
|
|
316
|
+
.slice(0, 3)
|
|
317
|
+
if (ext.length) lines.push('\n[外部记忆 — 其他 AI 工具遗产,可继承]\n' + ext.join('\n'))
|
|
318
|
+
const sess = this.external.cache.filter((x) => x.kind === 'sessions')
|
|
319
|
+
if (sess.length) {
|
|
320
|
+
lines.push('· 历史会话索引: ' + sess.map((x) => x.name + ' ' + x.files.length + ' 个').join(', ') + ' —— 需要时用 memory_recall 检索。')
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
lines.push('\n[记忆写入纪律 — 必须遵守]')
|
|
324
|
+
lines.push('- 会话开始:若任务与历史工作/历史决策相关,先回顾以上记忆;**遇到不熟悉的代码、领域或项目时,主动调用 memory_recall 检索本机所有 AI 工具的历史记忆(CodeBuddy/Claude Code/Codex 等会话),不要凭空猜测**。')
|
|
325
|
+
lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆。')
|
|
326
|
+
lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
|
|
327
|
+
lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
|
|
328
|
+
lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
|
|
329
|
+
lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
|
|
330
|
+
lines.push('- 定期调用 memory_maintain 归档 30 天前日志;不存密钥,除非用户明确要求。')
|
|
331
|
+
lines.push('- 记忆仅作补充,不替代正常回复与交付物。')
|
|
332
|
+
lines.push('</memory_system>')
|
|
333
|
+
return lines.join('\n')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 反思请求块:仅在会话首轮注入一次。 */
|
|
337
|
+
renderReflectionRequest() {
|
|
338
|
+
const pending = this.state.pendingReflection
|
|
339
|
+
if (!pending) return ''
|
|
340
|
+
if (this.state.reflectionShownSession === pending.date) return ''
|
|
341
|
+
this.state.reflectionShownSession = pending.date
|
|
342
|
+
const style = this.config.reflectStyle || 'auto'
|
|
343
|
+
const styleText = {
|
|
344
|
+
life: '生活化风格:轻松温暖的口吻,像朋友复盘一天,可以用少量 emoji,兼顾感受与生活平衡。',
|
|
345
|
+
professional: '专业性风格:简洁专业的总结,分条列出 成果 / 问题与教训 / 下一步要点。',
|
|
346
|
+
auto: '风格由内容决定:工作成果类用专业简洁分条;个人/生活类用轻松口吻;可适度结合。',
|
|
347
|
+
}[style] || '风格由内容决定。'
|
|
348
|
+
return [
|
|
349
|
+
'\n\n[昨日反思 — 待生成]',
|
|
350
|
+
'昨天(' + pending.date + ')你完成了以下工作:',
|
|
351
|
+
pending.text,
|
|
352
|
+
'请在本轮回复开头,以「昨日反思 · ' + pending.date + '」小节向用户呈现前一天的工作反思与要点:成果回顾、值得注意的教训或改进、今天可延续的要点。',
|
|
353
|
+
'要求:' + styleText,
|
|
354
|
+
'生成后调用 memory_reflect(date="' + pending.date + '", text=完整反思内容)保存,之后该提示不再出现。',
|
|
355
|
+
].join('\n')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------- 写操作 ----------
|
|
359
|
+
async appendText(p, text) {
|
|
360
|
+
const existing = await this.readTextSafe(p)
|
|
361
|
+
const body = existing ? existing.replace(/\s+$/, '') + '\n' + text : text
|
|
362
|
+
await mkdir(path.dirname(p), { recursive: true })
|
|
363
|
+
await writeFile(p, body, 'utf8')
|
|
364
|
+
return body
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async writeFull(p, text) {
|
|
368
|
+
await mkdir(path.dirname(p), { recursive: true })
|
|
369
|
+
await writeFile(p, text, 'utf8')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------- 检索 ----------
|
|
373
|
+
async recall(query, limit = 8, agent) {
|
|
374
|
+
const q = String(query || '').toLowerCase().trim()
|
|
375
|
+
if (!q) return 'memory_recall: query 为空。'
|
|
376
|
+
const p = await this.resolvePaths(agent)
|
|
377
|
+
const out = []
|
|
378
|
+
const hits = []
|
|
379
|
+
const scanFile = async (label, filePath, maxMatches = 3) => {
|
|
380
|
+
const text = await this.readTextSafe(filePath)
|
|
381
|
+
if (!text) return
|
|
382
|
+
const matched = []
|
|
383
|
+
for (const line of text.split('\n')) {
|
|
384
|
+
if (line.toLowerCase().includes(q)) {
|
|
385
|
+
matched.push(line.trim().slice(0, 200))
|
|
386
|
+
if (matched.length >= maxMatches) break
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (matched.length) hits.push({ where: label, matches: matched })
|
|
390
|
+
}
|
|
391
|
+
// 读取顺序:progress(日志/反思)先行,再读 memory(用户级/项目笔记)
|
|
392
|
+
const logs = await this.listDailyLogs(p.projectDir, 40)
|
|
393
|
+
for (const log of logs) {
|
|
394
|
+
if (hits.length >= limit) break
|
|
395
|
+
await scanFile(log.name, path.join(p.projectDir, log.name), 2)
|
|
396
|
+
}
|
|
397
|
+
const reflections = await this.listReflections(p.reflectDir, 30)
|
|
398
|
+
for (const r of reflections) {
|
|
399
|
+
if (hits.length >= limit) break
|
|
400
|
+
await scanFile('reflections/' + r.name, path.join(p.reflectDir, r.name), 2)
|
|
401
|
+
}
|
|
402
|
+
await scanFile('~' + p.userFile.slice(homedir().length), p.userFile)
|
|
403
|
+
await scanFile(p.projectDir + '/MEMORY.md', p.notesPath)
|
|
404
|
+
if (hits.length) {
|
|
405
|
+
out.push('== 本地记忆文件命中 ==')
|
|
406
|
+
for (const h of hits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
|
|
407
|
+
}
|
|
408
|
+
// 外部记忆(其他 AI 工具遗产)检索
|
|
409
|
+
try {
|
|
410
|
+
const extHits = await this.external.search(query, Math.max(limit - hits.length, 2))
|
|
411
|
+
if (extHits.length) {
|
|
412
|
+
out.push('== 外部记忆命中(CodeBuddy/Claude/Codex/项目约定等) ==')
|
|
413
|
+
for (const h of extHits) out.push('· ' + h.source + '(' + h.tool + '):\n' + h.lines.map((m) => ' - ' + m).join('\n'))
|
|
414
|
+
}
|
|
415
|
+
} catch (e) {}
|
|
416
|
+
// 历史会话检索(若部署启用 session-query 索引)
|
|
417
|
+
try {
|
|
418
|
+
const sq = this._sessionQuery
|
|
419
|
+
if (sq) {
|
|
420
|
+
const page = await sq.searchSessions({ query: String(query || ''), limit: Math.min(limit, 10) })
|
|
421
|
+
const items = (page && page.items) || []
|
|
422
|
+
if (items.length) {
|
|
423
|
+
out.push('== 历史 DSH 会话命中 ==')
|
|
424
|
+
for (const it of items) {
|
|
425
|
+
const hdr = it.header || {}
|
|
426
|
+
const when = hdr.createdAt ? dateStrOf(hdr.createdAt) : '?'
|
|
427
|
+
const snippet = it.bestMatch && it.bestMatch.snippet ? String(it.bestMatch.snippet).slice(0, 300) : ''
|
|
428
|
+
out.push('· [' + when + '] ' + (hdr.cwd || hdr.id || '?') + '\n ' + snippet)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} catch (e) {}
|
|
433
|
+
if (!out.length) return '未找到与 "' + query + '" 相关的记忆。'
|
|
434
|
+
return out.join('\n')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------- 反思 ----------
|
|
438
|
+
async saveReflection(date, text, agent) {
|
|
439
|
+
if (!DATE_RE.test(date)) return 'memory_reflect: date 必须是 YYYY-MM-DD。'
|
|
440
|
+
const content = String(text || '').trim()
|
|
441
|
+
if (!content) return 'memory_reflect: text 为空,未保存。'
|
|
442
|
+
const p = await this.resolvePaths(agent)
|
|
443
|
+
const file = path.join(p.reflectDir, date + '.md')
|
|
444
|
+
await this.writeFull(file, '# 反思 ' + date + '\n\n' + content)
|
|
445
|
+
this.state.latestReflection = content
|
|
446
|
+
this.state.latestReflectionDate = date
|
|
447
|
+
if (this.state.pendingReflection && this.state.pendingReflection.date === date) {
|
|
448
|
+
this.state.pendingReflection = undefined
|
|
449
|
+
}
|
|
450
|
+
this.state.loadedAt = Date.now()
|
|
451
|
+
return '已保存反思 ' + file
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** 一键反思:自动取"有日志但无反思"的最早日期,按日志条目生成反思草稿并落盘。 */
|
|
455
|
+
async reflectAuto(agent) {
|
|
456
|
+
const p = await this.resolvePaths(agent)
|
|
457
|
+
const pending = await this.detectPendingReflection(p.projectDir, p.reflectDir)
|
|
458
|
+
const date = pending ? pending.date : (this.state.recentLogs[0] && this.state.recentLogs[0].date)
|
|
459
|
+
if (!date) return '没有可反思的日志(今天之前无日志记录)。'
|
|
460
|
+
const logFile = path.join(p.projectDir, date + '.md')
|
|
461
|
+
const logText = await this.readTextSafe(logFile)
|
|
462
|
+
const entries = logText.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- '))
|
|
463
|
+
const bullet = entries.length ? entries.map((l) => '- ' + l.slice(2)).join('\n') : '(无条目)'
|
|
464
|
+
const text = [
|
|
465
|
+
'## 成果回顾',
|
|
466
|
+
bullet,
|
|
467
|
+
'## 问题与教训',
|
|
468
|
+
'- (待补充)',
|
|
469
|
+
'## 下一步要点',
|
|
470
|
+
'- (待补充)',
|
|
471
|
+
].join('\n\n')
|
|
472
|
+
return this.saveReflection(date, text, agent)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------- 维护 ----------
|
|
476
|
+
async maintain(days = 30, agent) {
|
|
477
|
+
const p = await this.resolvePaths(agent)
|
|
478
|
+
const cutoff = new Date()
|
|
479
|
+
cutoff.setDate(cutoff.getDate() - days)
|
|
480
|
+
const logs = await this.listDailyLogs(p.projectDir, 365)
|
|
481
|
+
const oldLogs = logs.filter((log) => {
|
|
482
|
+
const m = DATE_RE.exec(log.date)
|
|
483
|
+
if (!m) return false
|
|
484
|
+
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) < cutoff
|
|
485
|
+
})
|
|
486
|
+
if (!oldLogs.length) return '没有超过 ' + days + ' 天的日志,无需归档。'
|
|
487
|
+
let archive = '\n## 归档日志(归档于 ' + todayStr() + ')'
|
|
488
|
+
let archivedBytes = 0
|
|
489
|
+
for (const log of oldLogs) {
|
|
490
|
+
const text = await this.readTextSafe(path.join(p.projectDir, log.name))
|
|
491
|
+
if (text) {
|
|
492
|
+
archive += '\n\n### ' + log.name + '\n' + text
|
|
493
|
+
archivedBytes += text.length
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const notesBody = await this.appendText(p.notesPath, archive)
|
|
497
|
+
this.state.notesText = notesBody
|
|
498
|
+
const deleted = []
|
|
499
|
+
const kept = []
|
|
500
|
+
for (const log of oldLogs) {
|
|
501
|
+
try {
|
|
502
|
+
await rm(path.join(p.projectDir, log.name), { force: true })
|
|
503
|
+
deleted.push(log.name)
|
|
504
|
+
} catch (e) { kept.push(log.name) }
|
|
505
|
+
}
|
|
506
|
+
this.state.loadedAt = Date.now()
|
|
507
|
+
return '已归档 ' + oldLogs.length + ' 个日志文件(' + archivedBytes + ' 字符)到 ' + p.notesPath +
|
|
508
|
+
(deleted.length ? '\n已删除: ' + deleted.join(', ') : '') +
|
|
509
|
+
(kept.length ? '\n未删除(可手动清理): ' + kept.join(', ') : '')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------- 状态快照(UI) ----------
|
|
513
|
+
async snapshot(agent) {
|
|
514
|
+
await this.refresh(agent)
|
|
515
|
+
const p = await this.resolvePaths(agent)
|
|
516
|
+
const todayEntries = this.state.logText.split('\n').filter((l) => l.trim().startsWith('- ')).length
|
|
517
|
+
return {
|
|
518
|
+
config: this.config,
|
|
519
|
+
ws: this.state.ws,
|
|
520
|
+
userDir: p.userDir,
|
|
521
|
+
projectDir: p.projectDir,
|
|
522
|
+
userFile: p.userFile,
|
|
523
|
+
notesPath: p.notesPath,
|
|
524
|
+
logPath: this.state.logPath,
|
|
525
|
+
reflectDir: p.reflectDir,
|
|
526
|
+
sizes: {
|
|
527
|
+
user: this.state.userText.length,
|
|
528
|
+
notes: this.state.notesText.length,
|
|
529
|
+
log: this.state.logText.length,
|
|
530
|
+
},
|
|
531
|
+
todayEntries,
|
|
532
|
+
latestReflectionDate: this.state.latestReflectionDate,
|
|
533
|
+
pendingReflection: this.state.pendingReflection ? this.state.pendingReflection.date : undefined,
|
|
534
|
+
refreshedAt: this.state.loadedAt,
|
|
535
|
+
configReadError: this._readError,
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ---------- 工具定义(手构,无 dsh-tools 依赖) ----------
|
|
541
|
+
function defineTool(name, description, parameters, execute) {
|
|
542
|
+
const properties = {}
|
|
543
|
+
const required = []
|
|
544
|
+
for (const [key, spec] of Object.entries(parameters || {})) {
|
|
545
|
+
const prop = { type: spec.type || 'string', description: spec.description || '' }
|
|
546
|
+
if (spec.enum) prop.enum = spec.enum
|
|
547
|
+
properties[key] = prop
|
|
548
|
+
if (spec.required) required.push(key)
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
name,
|
|
552
|
+
description,
|
|
553
|
+
parameters: { type: 'object', properties, required },
|
|
554
|
+
output: {
|
|
555
|
+
schema: { type: 'string' },
|
|
556
|
+
render(_args, value) { return [{ type: 'text', text: String(value) }] },
|
|
557
|
+
},
|
|
558
|
+
async execute(args, exec) {
|
|
559
|
+
try {
|
|
560
|
+
return await execute(args, exec)
|
|
561
|
+
} catch (e) {
|
|
562
|
+
return name + ' 失败: ' + (e && e.message ? e.message : String(e))
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------- HTTP 辅助 ----------
|
|
569
|
+
function isLoopbackRequest(req) {
|
|
570
|
+
const address = req.socket && req.socket.remoteAddress
|
|
571
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
572
|
+
const host = req.headers.host
|
|
573
|
+
if (typeof host !== 'string') return false
|
|
574
|
+
let hostUrl
|
|
575
|
+
try { hostUrl = new URL('http://' + host) } catch { return false }
|
|
576
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
577
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
578
|
+
const origin = req.headers.origin
|
|
579
|
+
if (origin === undefined) return true
|
|
580
|
+
try { return new URL(origin).host === hostUrl.host } catch { return false }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function writeJson(res, status, body) {
|
|
584
|
+
const payload = JSON.stringify(body)
|
|
585
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
586
|
+
res.end(payload)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
590
|
+
const chunks = []
|
|
591
|
+
let size = 0
|
|
592
|
+
for await (const chunk of req) {
|
|
593
|
+
size += chunk.length
|
|
594
|
+
if (size > maxBytes) return undefined
|
|
595
|
+
chunks.push(chunk)
|
|
596
|
+
}
|
|
597
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return undefined }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 路径白名单:仅允许记忆目录内的文件。 */
|
|
601
|
+
function isUnderMemoryTree(engine, target) {
|
|
602
|
+
const resolved = path.resolve(target)
|
|
603
|
+
const roots = []
|
|
604
|
+
try { roots.push(path.resolve(engine.userDirOf())) } catch (e) {}
|
|
605
|
+
if (engine.state.projectDir) roots.push(path.resolve(engine.state.projectDir))
|
|
606
|
+
return roots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
610
|
+
// 外部记忆接入(其他 AI 工具的记忆继承)
|
|
611
|
+
//
|
|
612
|
+
// 目标:让 DSH 继承用户在 CodeBuddy / Claude Code / Codex / Cursor 等
|
|
613
|
+
// 工具中积累的记忆,持续拟合用户画像。
|
|
614
|
+
//
|
|
615
|
+
// 源分三类:
|
|
616
|
+
// - markdown 记忆(用户级/画像/项目约定):内容小,直接读入缓存,注入/检索/接入
|
|
617
|
+
// - 会话日志(jsonl,各工具 projects / sessions):
|
|
618
|
+
// 只列索引,检索时按需扫描(行数/文件数上限),绝不整库注入
|
|
619
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
620
|
+
class ExternalMemory {
|
|
621
|
+
constructor(engine) {
|
|
622
|
+
this.engine = engine
|
|
623
|
+
this.cache = undefined // [{id,name,tool,kind,files,content,size,mtime}]
|
|
624
|
+
this.cachedAt = 0
|
|
625
|
+
this._scanning = undefined
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
enabled(id) {
|
|
629
|
+
const map = this.engine.config.externalSources || {}
|
|
630
|
+
return map[id] !== false
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** 递归收集某目录下的 jsonl 会话文件(按 mtime 取最新 N 个)。 */
|
|
634
|
+
async listSessionFiles(rootDir, limit = 20) {
|
|
635
|
+
const out = []
|
|
636
|
+
const walk = async (dir, depth) => {
|
|
637
|
+
if (depth > 5 || out.length >= limit * 3) return
|
|
638
|
+
let entries
|
|
639
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch (e) { return }
|
|
640
|
+
for (const e of entries) {
|
|
641
|
+
if (out.length >= limit * 3) return
|
|
642
|
+
const full = path.join(dir, e.name)
|
|
643
|
+
if (e.isDirectory()) await walk(full, depth + 1)
|
|
644
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
645
|
+
try {
|
|
646
|
+
const info = await stat(full)
|
|
647
|
+
out.push({ path: full, size: info.size, mtime: info.mtimeMs })
|
|
648
|
+
} catch (err) {}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
await walk(rootDir, 0)
|
|
653
|
+
out.sort((a, b) => b.mtime - a.mtime)
|
|
654
|
+
return out.slice(0, limit)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** 从单个 jsonl 会话文件提取可检索文本(行数/字节上限,防重)。 */
|
|
658
|
+
async extractSessionText(file, maxLines = 400, maxChars = 60000) {
|
|
659
|
+
let text = ''
|
|
660
|
+
let lines = 0
|
|
661
|
+
try {
|
|
662
|
+
const stream = createReadStream(file, { encoding: 'utf8', highWaterMark: 64 * 1024 })
|
|
663
|
+
for await (const chunk of stream) {
|
|
664
|
+
const lineChunks = String(chunk).split('\n')
|
|
665
|
+
for (const line of lineChunks) {
|
|
666
|
+
if (lines >= maxLines || text.length >= maxChars) break
|
|
667
|
+
lines++
|
|
668
|
+
if (!line.trim()) continue
|
|
669
|
+
const bits = extractJsonText(line)
|
|
670
|
+
if (bits) {
|
|
671
|
+
text += bits + '\n'
|
|
672
|
+
if (text.length >= maxChars) break
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
} catch (e) {}
|
|
677
|
+
return text.slice(0, maxChars)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* 探测全部启用的外部记忆源。结果缓存 3 分钟。
|
|
682
|
+
* markdown 源携带 content;会话源只带文件索引。
|
|
683
|
+
*/
|
|
684
|
+
async discover(force) {
|
|
685
|
+
if (!force && this.cache && Date.now() - this.cachedAt < 180000) return this.cache
|
|
686
|
+
if (this._scanning) return this._scanning
|
|
687
|
+
this._scanning = (async () => {
|
|
688
|
+
const home = homedir()
|
|
689
|
+
const ws = this.engine.state.ws || process.cwd()
|
|
690
|
+
const srcs = []
|
|
691
|
+
const pushMd = async (id, name, tool, kind, paths) => {
|
|
692
|
+
if (!this.enabled(id)) return
|
|
693
|
+
const files = []
|
|
694
|
+
let content = ''
|
|
695
|
+
let size = 0
|
|
696
|
+
let mtime = 0
|
|
697
|
+
for (const p of paths) {
|
|
698
|
+
try {
|
|
699
|
+
const info = await stat(p)
|
|
700
|
+
if (!info.isFile()) continue
|
|
701
|
+
const c = await readFile(p, 'utf8')
|
|
702
|
+
files.push({ path: p, size: info.size, mtime: info.mtimeMs })
|
|
703
|
+
size += info.size
|
|
704
|
+
mtime = Math.max(mtime, info.mtimeMs)
|
|
705
|
+
content += (content ? '\n\n' : '') + c
|
|
706
|
+
} catch (e) {}
|
|
707
|
+
}
|
|
708
|
+
if (!files.length) return
|
|
709
|
+
srcs.push({ id, name, tool, kind, files, content: content.slice(0, 200000), size, mtime })
|
|
710
|
+
}
|
|
711
|
+
const pushSessions = async (id, name, tool, rootDir) => {
|
|
712
|
+
if (!this.enabled(id)) return
|
|
713
|
+
const files = await this.listSessionFiles(rootDir)
|
|
714
|
+
if (!files.length) return
|
|
715
|
+
srcs.push({
|
|
716
|
+
id, name, tool, kind: 'sessions', files,
|
|
717
|
+
content: '', size: files.reduce((a, f) => a + f.size, 0),
|
|
718
|
+
mtime: files[0].mtime,
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// —— 用户级/画像类 markdown ——
|
|
723
|
+
await pushMd('workbuddy-user', 'AI 助手用户记忆', 'AI 助手', 'user', [path.join(home, '.workbuddy', 'MEMORY.md')])
|
|
724
|
+
const wbProfiles = await globOne(path.join(home, '.workbuddy', 'memory'), /_memory\.md$/, 3)
|
|
725
|
+
await pushMd('workbuddy-profile', 'AI 助手云端画像', 'AI 助手', 'profile', wbProfiles)
|
|
726
|
+
const cbMems = await globOne(path.join(home, '.codebuddy', 'memery'), /_memery\.md$/, 3)
|
|
727
|
+
await pushMd('codebuddy-memory', 'CodeBuddy 记忆画像', 'CodeBuddy', 'profile', cbMems)
|
|
728
|
+
await pushMd('claude-global', 'Claude Code 全局记忆', 'Claude Code', 'user', [path.join(home, '.claude', 'CLAUDE.md')])
|
|
729
|
+
// —— 项目约定类 ——
|
|
730
|
+
const conventions = [
|
|
731
|
+
path.join(ws, 'CLAUDE.md'), path.join(ws, 'AGENTS.md'), path.join(ws, 'CODEBUDDY.md'),
|
|
732
|
+
path.join(ws, 'Windsurf.md'), path.join(ws, '.github', 'copilot-instructions.md'),
|
|
733
|
+
]
|
|
734
|
+
const cursorRules = await globOne(path.join(ws, '.cursor', 'rules'), /\.(mdc|md)$/, 10)
|
|
735
|
+
await pushMd('project-conventions', '项目约定(CLAUDE.md 等)', '项目文件', 'project', [...conventions, ...cursorRules])
|
|
736
|
+
// —— 会话类 ——
|
|
737
|
+
await pushSessions('workbuddy-sessions', 'AI 助手历史会话', 'AI 助手', path.join(home, '.workbuddy', 'projects'))
|
|
738
|
+
await pushSessions('claude-sessions', 'Claude Code 历史会话', 'Claude Code', path.join(home, '.claude', 'projects'))
|
|
739
|
+
await pushSessions('codex-sessions', 'Codex 历史会话', 'Codex', path.join(home, '.codex', 'sessions'))
|
|
740
|
+
|
|
741
|
+
this.cache = srcs
|
|
742
|
+
this.cachedAt = Date.now()
|
|
743
|
+
return srcs
|
|
744
|
+
})().finally(() => { this._scanning = undefined })
|
|
745
|
+
return this._scanning
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** 汇总注入用的外部记忆摘要(按预算截断,会话源只报数量)。 */
|
|
749
|
+
async injectionText(budget = 1400) {
|
|
750
|
+
try {
|
|
751
|
+
const srcs = await this.discover(false)
|
|
752
|
+
const parts = []
|
|
753
|
+
const md = srcs.filter((s) => s.kind !== 'sessions')
|
|
754
|
+
const sess = srcs.filter((s) => s.kind === 'sessions')
|
|
755
|
+
let used = 0
|
|
756
|
+
for (const s of md) {
|
|
757
|
+
if (used >= budget) break
|
|
758
|
+
const head = truncateHead(s.content, Math.min(700, budget - used))
|
|
759
|
+
used += head.length
|
|
760
|
+
parts.push('· ' + s.name + '(' + s.tool + '):\n' + head)
|
|
761
|
+
}
|
|
762
|
+
if (sess.length) {
|
|
763
|
+
const total = sess.reduce((a, s) => a + s.files.length, 0)
|
|
764
|
+
parts.push('· 历史会话可用: ' + sess.map((s) => s.name + ' ' + s.files.length + ' 个').join(', ') + '(需要时用 memory_recall 检索)')
|
|
765
|
+
}
|
|
766
|
+
if (!parts.length) return ''
|
|
767
|
+
return '### 外部记忆(其他 AI 工具遗产)\n' + parts.join('\n\n')
|
|
768
|
+
} catch (e) { return '' }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** 检索外部记忆(全源)。返回 {source, lines[]} 列表。 */
|
|
772
|
+
async search(query, limit = 6) {
|
|
773
|
+
const q = String(query || '').toLowerCase().trim()
|
|
774
|
+
if (!q) return []
|
|
775
|
+
const srcs = await this.discover(false)
|
|
776
|
+
const out = []
|
|
777
|
+
for (const s of srcs) {
|
|
778
|
+
if (out.length >= limit) break
|
|
779
|
+
const hits = []
|
|
780
|
+
if (s.kind === 'sessions') {
|
|
781
|
+
let scanned = 0
|
|
782
|
+
for (const f of s.files) {
|
|
783
|
+
if (hits.length >= 3 || scanned >= 8 || out.length >= limit) break
|
|
784
|
+
scanned++
|
|
785
|
+
const text = await this.extractSessionText(f.path)
|
|
786
|
+
for (const line of text.split('\n')) {
|
|
787
|
+
if (line.toLowerCase().includes(q)) {
|
|
788
|
+
hits.push('(' + path.basename(f.path).slice(0, 20) + ') ' + line.trim().slice(0, 200))
|
|
789
|
+
if (hits.length >= 3) break
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
const matched = []
|
|
795
|
+
for (const line of s.content.split('\n')) {
|
|
796
|
+
if (line.toLowerCase().includes(q)) {
|
|
797
|
+
matched.push(line.trim().slice(0, 200))
|
|
798
|
+
if (matched.length >= 3) break
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
hits.push(...matched)
|
|
802
|
+
}
|
|
803
|
+
if (hits.length) out.push({ source: s.name, tool: s.tool, kind: s.kind, lines: hits })
|
|
804
|
+
}
|
|
805
|
+
return out
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** 把某个源的内容接入本地记忆(项目笔记或用户级记忆)。 */
|
|
809
|
+
async importInto(sourceId, target, engine, agent) {
|
|
810
|
+
const srcs = await this.discover(false)
|
|
811
|
+
const src = srcs.find((s) => s.id === sourceId)
|
|
812
|
+
if (!src) return '外部源不存在: ' + sourceId
|
|
813
|
+
if (src.kind === 'sessions') return '会话类源不支持整体接入,请用 memory_recall 按需检索(' + src.files.length + ' 个会话文件)。'
|
|
814
|
+
const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + todayStr()
|
|
815
|
+
if (target === 'user') {
|
|
816
|
+
const p = await engine.resolvePaths(agent)
|
|
817
|
+
const body = await engine.appendText(p.userFile, '\n' + stamp + '\n' + src.content.slice(0, 120000))
|
|
818
|
+
engine.state.userText = body
|
|
819
|
+
return '已接入用户级记忆(' + src.name + ', ' + src.content.length + ' 字符)'
|
|
820
|
+
}
|
|
821
|
+
const p = await engine.resolvePaths(agent)
|
|
822
|
+
const body = await engine.appendText(p.notesPath, '\n' + stamp + '\n' + src.content.slice(0, 120000))
|
|
823
|
+
engine.state.notesText = body
|
|
824
|
+
engine.state.loadedAt = Date.now()
|
|
825
|
+
return '已接入项目笔记(' + src.name + ', ' + src.content.length + ' 字符)'
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** 简化状态视图(UI 用)。 */
|
|
829
|
+
async summarize() {
|
|
830
|
+
const srcs = await this.discover(false)
|
|
831
|
+
return srcs.map((s) => ({
|
|
832
|
+
id: s.id, name: s.name, tool: s.tool, kind: s.kind,
|
|
833
|
+
fileCount: s.files.length, size: s.size, mtime: s.mtime,
|
|
834
|
+
preview: s.kind === 'sessions' ? '' : truncateHead(s.content, 240),
|
|
835
|
+
enabled: this.enabled(s.id),
|
|
836
|
+
}))
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** 递归收集目录下匹配正则的文件(上限 n)。 */
|
|
841
|
+
async function globOne(dir, re, limit) {
|
|
842
|
+
const out = []
|
|
843
|
+
const walk = async (d, depth) => {
|
|
844
|
+
if (depth > 4 || out.length >= limit) return
|
|
845
|
+
let entries
|
|
846
|
+
try { entries = await readdir(d, { withFileTypes: true }) } catch (e) { return }
|
|
847
|
+
for (const e of entries) {
|
|
848
|
+
if (out.length >= limit) return
|
|
849
|
+
const full = path.join(d, e.name)
|
|
850
|
+
if (e.isDirectory()) await walk(full, depth + 1)
|
|
851
|
+
else if (e.isFile() && re.test(e.name)) out.push(full)
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
await walk(dir, 0)
|
|
855
|
+
return out
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** 从一条 jsonl 会话行提取文本片段(兼容 claude/codex 等格式)。 */
|
|
859
|
+
function extractJsonText(line) {
|
|
860
|
+
try {
|
|
861
|
+
const obj = JSON.parse(line)
|
|
862
|
+
const parts = []
|
|
863
|
+
const walk = (v, depth) => {
|
|
864
|
+
if (depth > 8 || parts.length >= 6) return
|
|
865
|
+
if (typeof v === 'string') return
|
|
866
|
+
if (Array.isArray(v)) { for (const it of v) walk(it, depth + 1); return }
|
|
867
|
+
if (v && typeof v === 'object') {
|
|
868
|
+
for (const key of Object.keys(v)) {
|
|
869
|
+
const val = v[key]
|
|
870
|
+
if (key === 'text' && typeof val === 'string' && val.trim()) parts.push(val.trim())
|
|
871
|
+
else if (key === 'input_text' && typeof val === 'string' && val.trim()) parts.push(val.trim())
|
|
872
|
+
else if ((key === 'content' || key === 'message') && val) walk(val, depth + 1)
|
|
873
|
+
else if (key === 'summary' && typeof val === 'string' && val.trim()) parts.push(val.trim())
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
walk(obj, 0)
|
|
878
|
+
const joined = parts.join(' | ').slice(0, 600)
|
|
879
|
+
return joined || undefined
|
|
880
|
+
} catch (e) { return undefined }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Mount the memory engine: routes, tools, prompt section, reflection hooks.
|
|
885
|
+
*/
|
|
886
|
+
export function apply(ctx, config) {
|
|
887
|
+
const engine = new MemoryEngine()
|
|
888
|
+
const sessionQuery = ctx.get('sessionQuery')
|
|
889
|
+
engine._sessionQuery = sessionQuery
|
|
890
|
+
|
|
891
|
+
// 生命周期刷新
|
|
892
|
+
const refreshAll = (agent) => { void engine.refresh(agent) }
|
|
893
|
+
refreshAll()
|
|
894
|
+
ctx.on('agent/session-start', (payload) => refreshAll(payload && payload.agent))
|
|
895
|
+
ctx.on('agent/turn-stopping', (payload) => refreshAll(payload && payload.agent))
|
|
896
|
+
|
|
897
|
+
// ---------- 系统提示词注入 ----------
|
|
898
|
+
const disposeSection = ctx.systemPrompt.section({
|
|
899
|
+
name: 'dsh:auto-memory',
|
|
900
|
+
order: SECTION_ORDER,
|
|
901
|
+
text: (context) => {
|
|
902
|
+
try {
|
|
903
|
+
const agent = context && context.agent
|
|
904
|
+
if (!agent) return ''
|
|
905
|
+
if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 60000) {
|
|
906
|
+
void engine.refresh(agent)
|
|
907
|
+
}
|
|
908
|
+
return engine.renderMemory(context) + engine.renderReflectionRequest()
|
|
909
|
+
} catch (e) { return '' }
|
|
910
|
+
},
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
// ---------- 工具 ----------
|
|
914
|
+
const tools = [
|
|
915
|
+
defineTool('memory_log', '向当前工作区的 .dsh-memory/ 今日日志追加一条工作记录(append-only,自动建目录/文件)。完成实质性工作(改代码/修 bug/写文档/重构/技术选型/用户偏好约定)后必须调用;有跨会话长期价值的内容在同一轮内一并写入记忆(memory_note 项目/ memory_user 跨项目),progress 与 memory 一起写;不要记录临时信息。', {
|
|
916
|
+
note: { type: 'string', required: true, description: '简短条目:一句话概括做了什么、结果如何。' },
|
|
917
|
+
date: { type: 'string', description: '日志日期 YYYY-MM-DD,缺省今天。' },
|
|
918
|
+
}, async (args, exec) => {
|
|
919
|
+
const date = DATE_RE.test(args.date || '') ? args.date : todayStr()
|
|
920
|
+
const p = await engine.resolvePaths(exec.agent)
|
|
921
|
+
const logPath = path.join(p.projectDir, date + '.md')
|
|
922
|
+
const entry = '- ' + nowHm() + ' ' + String(args.note).trim()
|
|
923
|
+
const body = await engine.appendText(logPath, entry)
|
|
924
|
+
if (date === todayStr()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
|
|
925
|
+
return '已追加到 ' + logPath + '\n' + entry
|
|
926
|
+
}),
|
|
927
|
+
|
|
928
|
+
defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点)。action=append 追加一段(自动带日期标题);action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容)。', {
|
|
929
|
+
content: { type: 'string', required: true, description: '笔记内容。' },
|
|
930
|
+
action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
|
|
931
|
+
}, async (args, exec) => {
|
|
932
|
+
const p = await engine.resolvePaths(exec.agent)
|
|
933
|
+
const content = String(args.content || '').trim()
|
|
934
|
+
if (!content) return 'memory_note: content 为空,未写入。'
|
|
935
|
+
let body
|
|
936
|
+
if (args.action === 'replace') {
|
|
937
|
+
body = content
|
|
938
|
+
await engine.writeFull(p.notesPath, body)
|
|
939
|
+
} else {
|
|
940
|
+
body = await engine.appendText(p.notesPath, '\n## ' + todayStr() + '\n' + content)
|
|
941
|
+
}
|
|
942
|
+
engine.state.notesText = body; engine.state.loadedAt = Date.now()
|
|
943
|
+
return '已更新 ' + p.notesPath
|
|
944
|
+
}),
|
|
945
|
+
|
|
946
|
+
defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。', {
|
|
947
|
+
content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
|
|
948
|
+
action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
|
|
949
|
+
}, async (args, exec) => {
|
|
950
|
+
const p = await engine.resolvePaths(exec.agent)
|
|
951
|
+
const content = String(args.content || '').trim()
|
|
952
|
+
if (!content) return 'memory_user: content 为空,未写入。'
|
|
953
|
+
let body
|
|
954
|
+
if (args.action === 'replace') {
|
|
955
|
+
body = content
|
|
956
|
+
await engine.writeFull(p.userFile, body)
|
|
957
|
+
} else {
|
|
958
|
+
body = await engine.appendText(p.userFile, '\n## ' + todayStr() + '\n' + content)
|
|
959
|
+
}
|
|
960
|
+
engine.state.userText = body; engine.state.loadedAt = Date.now()
|
|
961
|
+
return '已更新 ' + p.userFile
|
|
962
|
+
}),
|
|
963
|
+
|
|
964
|
+
defineTool('memory_recall', '检索记忆:本地记忆文件(每日日志、项目笔记、用户级记忆、反思)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。用户提到过去的做法/讨论/决定而当前上下文没有时调用,查询必须自包含。', {
|
|
965
|
+
query: { type: 'string', required: true, description: '检索关键词或自包含描述。' },
|
|
966
|
+
limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
|
|
967
|
+
}, async (args, exec) => engine.recall(args.query, args.limit, exec.agent)),
|
|
968
|
+
|
|
969
|
+
defineTool('memory_maintain', '维护记忆:把 days(缺省30)天前的 .dsh-memory/ 每日日志原样归档进项目 MEMORY.md 的归档段,并删除旧日志文件。归档保底不丢信息,之后可按返回结果决定是否精简归档段。', {
|
|
970
|
+
days: { type: 'integer', description: '归档阈值天数,缺省 30。' },
|
|
971
|
+
}, async (args, exec) => engine.maintain(args.days, exec.agent)),
|
|
972
|
+
|
|
973
|
+
defineTool('memory_status', '查看自动记忆的当前状态:存储位置、各记忆文件大小、今日日志条数、待反思、上次刷新时间。用于确认记忆系统工作正常。', {}, async (_args, exec) => {
|
|
974
|
+
const snap = await engine.snapshot(exec.agent)
|
|
975
|
+
const lines = []
|
|
976
|
+
lines.push('工作区: ' + snap.ws)
|
|
977
|
+
lines.push('用户级记忆: ' + snap.userFile + ' — ' + snap.sizes.user + ' 字符')
|
|
978
|
+
lines.push('项目笔记: ' + snap.notesPath + ' — ' + snap.sizes.notes + ' 字符')
|
|
979
|
+
lines.push('今日日志: ' + snap.logPath + ' — ' + snap.sizes.log + ' 字符, ' + snap.todayEntries + ' 条')
|
|
980
|
+
lines.push('最近反思: ' + (snap.latestReflectionDate || '(无)') + ' | 待反思: ' + (snap.pendingReflection || '(无)'))
|
|
981
|
+
lines.push('上次刷新: ' + (snap.refreshedAt ? new Date(snap.refreshedAt).toLocaleString() : '尚未'))
|
|
982
|
+
return lines.join('\n')
|
|
983
|
+
}),
|
|
984
|
+
|
|
985
|
+
defineTool('memory_reflect', '保存每日反思(在收到「昨日反思待生成」提示、并已在回复中呈现反思后调用)。将反思全文落盘到 .dsh-memory/reflections/YYYY-MM-DD.md,并标记该日反思完成。', {
|
|
986
|
+
date: { type: 'string', required: true, description: '反思对应的日期 YYYY-MM-DD(即被反思那天的日志日期)。' },
|
|
987
|
+
text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
|
|
988
|
+
}, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
|
|
989
|
+
|
|
990
|
+
defineTool('memory_external', '查看/接入其他 AI 工具(CodeBuddy/Claude Code/Codex/项目约定文件等)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 把某源内容整体接入本地记忆(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,自动标注来源)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
|
|
991
|
+
action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
|
|
992
|
+
source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
|
|
993
|
+
target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
|
|
994
|
+
}, async (args, exec) => {
|
|
995
|
+
if (args.action === 'list') {
|
|
996
|
+
const list = await engine.external.summarize()
|
|
997
|
+
if (!list.length) return '未检测到其他 AI 工具的记忆文件(可检查 ~/.codebuddy、~/.claude、~/.codex 等目录是否存在)。'
|
|
998
|
+
const lines = []
|
|
999
|
+
lines.push('检测到 ' + list.length + ' 个外部记忆源:')
|
|
1000
|
+
for (const s of list) {
|
|
1001
|
+
lines.push('· [' + s.id + '] ' + s.name + '(' + s.tool + ',' + s.kind + ') — ' + s.fileCount + ' 个文件, ' + fmtBytes(s.size) + (s.enabled ? '' : ',已停用'))
|
|
1002
|
+
if (s.preview) lines.push(' ' + s.preview.replace(/\n/g, ' | '))
|
|
1003
|
+
else lines.push(' (会话源,可检索不可整源预览)')
|
|
1004
|
+
}
|
|
1005
|
+
lines.push('接入: memory_external(action="import", source="<id>", target="project"|"user")')
|
|
1006
|
+
return lines.join('\n')
|
|
1007
|
+
}
|
|
1008
|
+
return engine.external.importInto(String(args.source || ''), args.target === 'user' ? 'user' : 'project', engine, exec.agent)
|
|
1009
|
+
}),
|
|
1010
|
+
]
|
|
1011
|
+
|
|
1012
|
+
// ---------- 路由 ----------
|
|
1013
|
+
const routes = [
|
|
1014
|
+
{
|
|
1015
|
+
kind: 'exact',
|
|
1016
|
+
path: API.state,
|
|
1017
|
+
handler: async (req, res) => {
|
|
1018
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1019
|
+
if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1020
|
+
try { writeJson(res, 200, await engine.snapshot()) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
kind: 'exact',
|
|
1025
|
+
path: API.list,
|
|
1026
|
+
handler: async (req, res) => {
|
|
1027
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1028
|
+
if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1029
|
+
try {
|
|
1030
|
+
const p = await engine.resolvePaths(undefined)
|
|
1031
|
+
const logs = await engine.listDailyLogs(p.projectDir, 60)
|
|
1032
|
+
const reflections = await engine.listReflections(p.reflectDir, 60)
|
|
1033
|
+
const sizeOf = async (f) => { try { return (await stat(f)).size } catch { return 0 } }
|
|
1034
|
+
writeJson(res, 200, {
|
|
1035
|
+
projectDir: p.projectDir,
|
|
1036
|
+
logs: await Promise.all(logs.map(async (l) => ({ ...l, size: await sizeOf(path.join(p.projectDir, l.name)) }))),
|
|
1037
|
+
reflections: await Promise.all(reflections.map(async (r) => ({ ...r, size: await sizeOf(path.join(p.reflectDir, r.name)) }))),
|
|
1038
|
+
notesSize: await sizeOf(p.notesPath),
|
|
1039
|
+
userSize: await sizeOf(p.userFile),
|
|
1040
|
+
})
|
|
1041
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
kind: 'exact',
|
|
1046
|
+
path: API.file,
|
|
1047
|
+
handler: async (req, res) => {
|
|
1048
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1049
|
+
if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1050
|
+
try {
|
|
1051
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
1052
|
+
let target = url.searchParams.get('path')
|
|
1053
|
+
if (!target) return writeJson(res, 400, { error: 'missing path' })
|
|
1054
|
+
// 先刷新路径缓存,避免用陈旧的工作区校验导致误 403
|
|
1055
|
+
await engine.refresh(undefined)
|
|
1056
|
+
const p = await engine.resolvePaths(undefined)
|
|
1057
|
+
// 相对文件名(如 2026-08-14.md / reflections/xxx.md)解析到项目记忆目录下
|
|
1058
|
+
if (!path.isAbsolute(target)) target = path.join(p.projectDir, target)
|
|
1059
|
+
if (!isUnderMemoryTree(engine, target)) return writeJson(res, 403, { error: 'path outside memory tree' })
|
|
1060
|
+
writeJson(res, 200, { path: path.resolve(target), content: await engine.readTextSafe(target) })
|
|
1061
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
kind: 'exact',
|
|
1066
|
+
path: API.recall,
|
|
1067
|
+
handler: async (req, res) => {
|
|
1068
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1069
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1070
|
+
const body = await readJsonBody(req)
|
|
1071
|
+
if (!body || typeof body.query !== 'string') return writeJson(res, 400, { error: 'invalid body' })
|
|
1072
|
+
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) }) }
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
kind: 'exact',
|
|
1077
|
+
path: API.config,
|
|
1078
|
+
handler: async (req, res) => {
|
|
1079
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1080
|
+
const method = req.method || 'GET'
|
|
1081
|
+
try {
|
|
1082
|
+
if (method === 'GET') {
|
|
1083
|
+
writeJson(res, 200, { config: await engine.loadConfig(), path: engine._configPath })
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
1086
|
+
if (method === 'POST' || method === 'PUT') {
|
|
1087
|
+
const body = await readJsonBody(req)
|
|
1088
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'invalid body' })
|
|
1089
|
+
const allowed = Object.keys(DEFAULT_CONFIG)
|
|
1090
|
+
const patch = {}
|
|
1091
|
+
for (const key of allowed) if (body[key] !== undefined) patch[key] = body[key]
|
|
1092
|
+
writeJson(res, 200, { config: await engine.saveConfig(patch) })
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
writeJson(res, 405, { error: 'method not allowed' })
|
|
1096
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
kind: 'exact',
|
|
1101
|
+
path: API.note,
|
|
1102
|
+
handler: async (req, res) => {
|
|
1103
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1104
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1105
|
+
const body = await readJsonBody(req)
|
|
1106
|
+
if (!body || typeof body.content !== 'string' || !body.content.trim()) return writeJson(res, 400, { error: 'invalid body' })
|
|
1107
|
+
try {
|
|
1108
|
+
const p = await engine.resolvePaths(undefined)
|
|
1109
|
+
const text = '\n## ' + todayStr() + '\n' + body.content.trim()
|
|
1110
|
+
const updated = await engine.appendText(p.notesPath, text)
|
|
1111
|
+
engine.state.notesText = updated
|
|
1112
|
+
engine.state.loadedAt = Date.now()
|
|
1113
|
+
writeJson(res, 200, { result: '已追加到 ' + p.notesPath })
|
|
1114
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
kind: 'exact',
|
|
1119
|
+
path: API.external,
|
|
1120
|
+
handler: async (req, res) => {
|
|
1121
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1122
|
+
if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1123
|
+
try { writeJson(res, 200, { sources: await engine.external.summarize() }) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1124
|
+
},
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
kind: 'exact',
|
|
1128
|
+
path: API['external-import'],
|
|
1129
|
+
handler: async (req, res) => {
|
|
1130
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1131
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1132
|
+
const body = await readJsonBody(req)
|
|
1133
|
+
if (!body || typeof body.source !== 'string') return writeJson(res, 400, { error: 'invalid body' })
|
|
1134
|
+
try {
|
|
1135
|
+
const result = await engine.external.importInto(body.source, body.target === 'user' ? 'user' : 'project', engine)
|
|
1136
|
+
writeJson(res, 200, { result })
|
|
1137
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1138
|
+
},
|
|
1139
|
+
},
|
|
1140
|
+
{
|
|
1141
|
+
kind: 'exact',
|
|
1142
|
+
path: API.reflect,
|
|
1143
|
+
handler: async (req, res) => {
|
|
1144
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1145
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1146
|
+
const body = await readJsonBody(req)
|
|
1147
|
+
if (!body || typeof body.date !== 'string' || typeof body.text !== 'string') return writeJson(res, 400, { error: 'invalid body' })
|
|
1148
|
+
try { writeJson(res, 200, { result: await engine.saveReflection(body.date, body.text) }) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
kind: 'exact',
|
|
1153
|
+
path: API['reflect-auto'],
|
|
1154
|
+
handler: async (req, res) => {
|
|
1155
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1156
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
1157
|
+
try { writeJson(res, 200, { result: await engine.reflectAuto() }) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
]
|
|
1161
|
+
|
|
1162
|
+
// ---------- 注册与清理 ----------
|
|
1163
|
+
const disposers = []
|
|
1164
|
+
disposers.push(disposeSection)
|
|
1165
|
+
for (const tool of tools) disposers.push(ctx.tools.register(tool))
|
|
1166
|
+
for (const route of routes) disposers.push(ctx.webServer.register(route))
|
|
1167
|
+
ctx.effect(() => () => {
|
|
1168
|
+
for (const dispose of disposers) { try { dispose() } catch (e) {} }
|
|
1169
|
+
}, 'dsh-auto-memory: surfaces')
|
|
1170
|
+
|
|
1171
|
+
console.log('[dsh-auto-memory] ready: engine + ' + tools.length + ' tools + injection + ' + routes.length + ' routes (external memory: ' + Object.keys(DEFAULT_CONFIG.externalSources).length + ' sources)')
|
|
1172
|
+
}
|